diff --git a/.dockerignore b/.dockerignore old mode 100644 new mode 100755 index 33e390a..cfdf0f2 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,14 @@ node_modules +webui/node_modules npm-debug.log .git +webui/.git .gitignore -.env +webui/.gitignore +.env* +webui/.env* +webui/.next *.md -!README.md \ No newline at end of file +!README.md +ollama/ +db/ \ No newline at end of file diff --git a/.env.example b/.env.example old mode 100644 new mode 100755 index af81e1d..db7a321 --- a/.env.example +++ b/.env.example @@ -5,8 +5,19 @@ 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 100644 new mode 100755 diff --git a/.github/workflows/njsscan.yml b/.github/workflows/njsscan.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml old mode 100644 new mode 100755 diff --git a/.github/workflows/update-authors.yml b/.github/workflows/update-authors.yml old mode 100644 new mode 100755 diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 6b42f1f..dbea724 --- a/.gitignore +++ b/.gitignore @@ -144,4 +144,13 @@ yt-dlp ffmpeg # Bun -bun.lock* \ No newline at end of file +bun.lock* + +# Ollama +ollama/ + +# Docker +docker-compose.yml + +# postgres +db/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules old mode 100644 new mode 100755 index cf3ce05..4a96795 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,3 @@ -[submodule "src/spamwatch"] - path = src/spamwatch +[submodule "telegram/spamwatch"] + path = telegram/spamwatch url = https://github.com/ABOCN/TelegramBot-SpamWatch diff --git a/AUTHORS b/AUTHORS old mode 100644 new mode 100755 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md old mode 100644 new mode 100755 diff --git a/Dockerfile b/Dockerfile old mode 100644 new mode 100755 index 7a0c006..f0d7341 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,37 @@ FROM oven/bun # Install ffmpeg and other deps -RUN apt-get update && apt-get install -y ffmpeg git && apt-get clean && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y \ + ffmpeg \ + git \ + supervisor \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* WORKDIR /usr/src/app COPY package*.json ./ +RUN bun install -RUN bun i +COPY webui/package*.json ./webui/ +WORKDIR /usr/src/app/webui +RUN bun install +WORKDIR /usr/src/app COPY . . -RUN chmod +x /usr/src/app/src/plugins/yt-dlp/yt-dlp +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 VOLUME /usr/src/app/.env -CMD ["bun", "start"] +EXPOSE 3000 + +ENV PYTHONUNBUFFERED=1 +ENV BUN_LOG_LEVEL=info + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 diff --git a/README.md b/README.md old mode 100644 new mode 100755 index 8fa5b60..36ad1dc --- a/README.md +++ b/README.md @@ -10,12 +10,6 @@ Kowalski is a a simple Telegram bot made in Node.js. - You can find Kowalski at [@KowalskiNodeBot](https://t.me/KowalskiNodeBot) on Telegram. -## Translations - - -Translation status - - ## Self-host requirements > [!IMPORTANT] @@ -25,21 +19,12 @@ 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 -## Running locally (non-Docker setup) +### AI Requirements -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. +- High-end CPU *or* GPU (~ 6GB vRAM) +- If using CPU, enough RAM to load the models (~6GB w/ defaults) ## Running with Docker @@ -55,9 +40,30 @@ 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!** -2. **Run the container** + 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** ```bash docker compose up -d @@ -69,30 +75,76 @@ If you prefer to use Docker directly, you can use these instructions instead. 1. **Make sure to setup your `.env` file first!** -2. **Build the image** + 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** ```bash docker build -t kowalski . ``` -3. **Run the container** +1. **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 @@ -100,12 +152,18 @@ If you prefer to use Docker directly, you can use these instructions instead. **Q:** I get a "Permission denied (EACCES)" error in the console when running the `/yt` command -**A:** Make sure `src/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so: +**A:** Make sure `telegram/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so: ```bash -chmod +x src/plugins/yt-dlp/yt-dlp +chmod +x telegram/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 @@ -117,3 +175,5 @@ 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 100644 new mode 100755 diff --git a/config/ai.ts b/config/ai.ts new file mode 100755 index 0000000..6c6ddb0 --- /dev/null +++ b/config/ai.ts @@ -0,0 +1,420 @@ +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 new file mode 100755 index 0000000..1fe94b3 --- /dev/null +++ b/config/settings.ts @@ -0,0 +1,2 @@ +export const seriesPageSize = 4; +export const modelPageSize = 4; \ No newline at end of file diff --git a/database/schema.ts b/database/schema.ts new file mode 100755 index 0000000..ce9a8ed --- /dev/null +++ b/database/schema.ts @@ -0,0 +1,52 @@ +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 deleted file mode 100644 index 0aab44a..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,9 +0,0 @@ -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 new file mode 100755 index 0000000..fe467ab --- /dev/null +++ b/docker-compose.yml.ai.example @@ -0,0 +1,30 @@ +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 new file mode 100755 index 0000000..d94e78d --- /dev/null +++ b/docker-compose.yml.example @@ -0,0 +1,24 @@ +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 new file mode 100755 index 0000000..51b8e1d --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +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 100644 new mode 100755 index 918bcb8..d9938ac --- a/nodemon.json +++ b/nodemon.json @@ -1,6 +1,6 @@ -{ - "ignore": ["src/props/*.json", "src/props/*.txt"], - "watch": ["src"], +{ + "ignore": ["telegram/props/*.json", "telegram/props/*.txt"], + "watch": ["telegram", "database", "config"], "ext": "ts,js", - "exec": "bun src/bot.ts" + "exec": "bun telegram/bot.ts" } \ No newline at end of file diff --git a/package.json b/package.json old mode 100644 new mode 100755 index 5c53440..b5c33cc --- a/package.json +++ b/package.json @@ -1,14 +1,25 @@ { "scripts": { - "start": "nodemon src/bot.ts" + "start": "nodemon telegram/bot.ts", + "docs": "bunx typedoc", + "serve:docs": "bun run serve-docs.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 deleted file mode 100644 index 3422e56..0000000 --- a/src/bot.ts +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 63cbb7b..0000000 --- a/src/commands/animal.ts +++ /dev/null @@ -1,132 +0,0 @@ -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 deleted file mode 100644 index ba0e3b4..0000000 --- a/src/commands/codename.ts +++ /dev/null @@ -1,69 +0,0 @@ -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/src/commands/fun.ts b/src/commands/fun.ts deleted file mode 100644 index c394241..0000000 --- a/src/commands/fun.ts +++ /dev/null @@ -1,113 +0,0 @@ -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/src/commands/help.ts b/src/commands/help.ts deleted file mode 100644 index 39191c1..0000000 --- a/src/commands/help.ts +++ /dev/null @@ -1,124 +0,0 @@ -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/src/commands/info.ts b/src/commands/info.ts deleted file mode 100644 index 2bf2b2d..0000000 --- a/src/commands/info.ts +++ /dev/null @@ -1,66 +0,0 @@ -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/src/commands/main.ts b/src/commands/main.ts deleted file mode 100644 index a5d581c..0000000 --- a/src/commands/main.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/src/commands/randompony.ts b/src/commands/randompony.ts deleted file mode 100644 index 175f283..0000000 --- a/src/commands/randompony.ts +++ /dev/null @@ -1,47 +0,0 @@ -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/src/locales/english.json b/src/locales/english.json deleted file mode 100644 index 0d9f6dd..0000000 --- a/src/locales/english.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "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 `${userName}, please select your device:`; - const options = { - parse_mode: 'HTML', - reply_to_message_id: ctx.message.message_id, - disable_web_page_preview: true, - reply_markup: { - inline_keyboard: results.map(result => [{ text: result.name, callback_data: `details:${result.url}:${ctx.from.id}` }]) - } + if (deviceSelectionCache[userId]?.timeout) { + clearTimeout(deviceSelectionCache[userId].timeout); + } + deviceSelectionCache[userId] = { + results, + timeout: setTimeout(() => { delete deviceSelectionCache[userId]; }, 5 * 60 * 1000) }; - 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(/details:(.+):(.+)/, async (ctx) => { - const url = ctx.match[1]; + bot.action(/gsmadetails:(\d+):(\d+)/, async (ctx) => { + const idx = parseInt(ctx.match[1]); const userId = parseInt(ctx.match[2]); const userName = getUsername(ctx); + const Strings = getStrings(languageCode(ctx)); const callbackQueryUserId = ctx.update.callback_query.from.id; if (userId !== callbackQueryUserId) { - return ctx.answerCbQuery(`${userName}, you are not allowed to interact with this.`); + return ctx.answerCbQuery(`${userName}, ${Strings.gsmarenaNotAllowed || "[TODO: Add gsmarenaNotAllowed to locales] you are not allowed to interact with this."}`); } ctx.answerCbQuery(); + const cache = deviceSelectionCache[userId]; + if (!cache || !cache.results[idx]) { + return ctx.reply(Strings.gsmarenaInvalidOrExpired || "[TODO: Add gsmarenaInvalidOrExpired to locales] Whoops, invalid or expired option. Please try again.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + } + const url = cache.results[idx].url; + const phoneDetails = await checkPhoneDetails(url); if (phoneDetails.name) { const message = formatPhone(phoneDetails); - ctx.editMessageText(`${userName}, these are the details of your device:` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); + ctx.editMessageText(`${userName}, ${Strings.gsmarenaDeviceDetails || "[TODO: Add gsmarenaDeviceDetails to locales] these are the details of your device:"}` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); } else { - ctx.reply("Error fetching phone details.", { reply_to_message_id: ctx.message.message_id }); + ctx.reply(Strings.gsmarenaErrorFetchingDetails || "[TODO: Add gsmarenaErrorFetchingDetails to locales] Error fetching phone details.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } }); }; diff --git a/telegram/commands/help.ts b/telegram/commands/help.ts new file mode 100755 index 0000000..9937bab --- /dev/null +++ b/telegram/commands/help.ts @@ -0,0 +1,154 @@ +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import type { Context } from 'telegraf'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +async function getUserAndStrings(ctx: Context, db?: any): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +function isAdmin(ctx: Context): boolean { + const userId = ctx.from?.id; + if (!userId) return false; + const adminArray = process.env.botAdmins ? process.env.botAdmins.split(',').map(id => parseInt(id.trim())) : []; + return adminArray.includes(userId); +} + +interface MessageOptions { + parse_mode: string; + disable_web_page_preview: boolean; + reply_markup: { + inline_keyboard: { text: string; callback_data: string; }[][]; + }; + reply_to_message_id?: number; +} + +async function sendHelpMessage(ctx, isEditing, db) { + const { Strings } = await getUserAndStrings(ctx, db); + const botInfo = await ctx.telegram.getMe(); + const helpText = Strings.botHelp + .replace(/{botName}/g, botInfo.first_name) + .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' }], + [{ text: Strings.ai.helpEntry, callback_data: 'helpAi' }] + ] + } + }; + if (includeReplyTo) { + const messageId = getMessageId(ctx); + if (messageId) { + (options as any).reply_parameters = { message_id: messageId }; + }; + }; + return options; + }; + if (isEditing) { + await ctx.editMessageText(helpText, createOptions(ctx)); + } else { + await ctx.reply(helpText, createOptions(ctx, true)); + }; +} + +export default (bot, db) => { + bot.help(spamwatchMiddleware, async (ctx) => { + await sendHelpMessage(ctx, false, db); + }); + + bot.command("about", spamwatchMiddleware, async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + const aboutMsg = Strings.botAbout.replace(/{sourceLink}/g, `${process.env.botSource}`); + ctx.reply(aboutMsg, { + parse_mode: 'Markdown', + disable_web_page_preview: true, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }); + + const options = (Strings) => ({ + parse_mode: 'Markdown', + disable_web_page_preview: true, + reply_markup: JSON.stringify({ + inline_keyboard: [ + [{ text: Strings.varStrings.varBack, callback_data: 'helpBack' }], + ] + }) + }); + + bot.action('helpMain', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.mainCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpUseful', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.usefulCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpInteractive', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.interactiveEmojisDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpFunny', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.funnyCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpLast', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.lastFm.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpYouTube', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ytDownload.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpAnimals', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.animalCommandsDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpMLP', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + await ctx.editMessageText(Strings.ponyApi.helpDesc, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpAi', async (ctx) => { + const { Strings } = await getUserAndStrings(ctx, db); + const helpText = isAdmin(ctx) ? Strings.ai.helpDescAdmin : Strings.ai.helpDesc; + await ctx.editMessageText(helpText, options(Strings)); + await ctx.answerCbQuery(); + }); + bot.action('helpBack', async (ctx) => { + await sendHelpMessage(ctx, true, db); + await ctx.answerCbQuery(); + }); +} diff --git a/src/commands/http.ts b/telegram/commands/http.ts old mode 100644 new mode 100755 similarity index 56% rename from src/commands/http.ts rename to telegram/commands/http.ts index b1fe636..5fd4ef9 --- a/src/commands/http.ts +++ b/telegram/commands/http.ts @@ -5,14 +5,40 @@ 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); -export default (bot: Telegraf) => { +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +export default (bot: Telegraf, db) => { bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'http-status')) return; + const reply_to_message_id = ctx.message.message_id; - const Strings = getStrings(languageCode(ctx)); + const { Strings } = await getUserAndStrings(ctx, db); const userInput = ctx.message.text.split(' ')[1]; const apiUrl = Resources.httpApi; const { invalidCode } = Strings.httpCodes @@ -34,24 +60,26 @@ export default (bot: Telegraf) => { .replace("{description}", codeInfo.description); await ctx.reply(message, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { await ctx.reply(Strings.httpCodes.notFound, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { - const message = Strings.httpCodes.fetchErr.replace("{error}", error); + const message = Strings.httpCodes.fetchErr.replace('{error}', error); ctx.reply(message, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); 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, ''); @@ -63,7 +91,7 @@ export default (bot: Telegraf) => { if (userInput.length !== 3) { ctx.reply(Strings.httpCodes.invalidCode, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }) return } @@ -74,12 +102,12 @@ export default (bot: Telegraf) => { await ctx.replyWithPhoto(apiUrl, { caption: `🐱 ${apiUrl}`, parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } catch (error) { ctx.reply(Strings.catImgErr, { parse_mode: 'Markdown', - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } }); diff --git a/telegram/commands/info.ts b/telegram/commands/info.ts new file mode 100755 index 0000000..597660b --- /dev/null +++ b/telegram/commands/info.ts @@ -0,0 +1,88 @@ +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { Context, Telegraf } from 'telegraf'; +import * as schema from '../../database/schema'; +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 }; +} + +async function getUserInfo(ctx: Context & { message: { text: string } }, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); + let lastName = ctx.from?.last_name; + if (lastName === undefined) { + lastName = " "; + } + const userInfo = Strings.userInfo + .replace('{userName}', `${ctx.from?.first_name} ${lastName}` || Strings.varStrings.varUnknown) + .replace('{userId}', ctx.from?.id || Strings.varStrings.varUnknown) + .replace('{userHandle}', ctx.from?.username ? `@${ctx.from?.username}` : Strings.varStrings.varNone) + .replace('{userPremium}', ctx.from?.is_premium ? Strings.varStrings.varYes : Strings.varStrings.varNo) + .replace('{userLang}', ctx.from?.language_code || Strings.varStrings.varUnknown); + return userInfo; +} + +async function getChatInfo(ctx: Context & { message: { text: string } }, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); + if ((ctx.chat?.type === 'group' || ctx.chat?.type === 'supergroup')) { + const chat = ctx.chat as (typeof ctx.chat & { username?: string; is_forum?: boolean }); + const chatInfo = Strings.chatInfo + .replace('{chatId}', chat?.id || Strings.varStrings.varUnknown) + .replace('{chatName}', chat?.title || Strings.varStrings.varUnknown) + .replace('{chatHandle}', chat?.username ? `@${chat.username}` : Strings.varStrings.varNone) + .replace('{chatMembersCount}', await ctx.getChatMembersCount()) + .replace('{chatType}', chat?.type || Strings.varStrings.varUnknown) + .replace('{isForum}', chat?.is_forum ? Strings.varStrings.varYes : Strings.varStrings.varNo); + return chatInfo; + } else { + return Strings.groupOnly; + } +} + +export default (bot: Telegraf, db) => { + bot.command('chatinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'info-commands')) return; + + const chatInfo = await getChatInfo(ctx, db); + ctx.reply( + chatInfo, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + } + ); + }); + + bot.command('userinfo', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'info-commands')) return; + + const userInfo = await getUserInfo(ctx, db); + ctx.reply( + userInfo, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + } + ); + }); +}; diff --git a/src/commands/lastfm.ts b/telegram/commands/lastfm.ts old mode 100644 new mode 100755 similarity index 84% rename from src/commands/lastfm.ts rename to telegram/commands/lastfm.ts index 39d6c8d..2ccecbf --- a/src/commands/lastfm.ts +++ b/telegram/commands/lastfm.ts @@ -4,13 +4,14 @@ 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 = 'src/props/lastfm.json'; +const dbFile = 'telegram/props/lastfm.json'; let users = {}; function loadUsers() { @@ -60,10 +61,12 @@ function getFromLast(track) { return imageUrl; } -export default (bot) => { +export default (bot, db) => { loadUsers(); - bot.command('setuser', (ctx) => { + bot.command('setuser', async (ctx) => { + if (await isCommandDisabled(ctx, db, 'lastfm')) return; + const userId = ctx.from.id; const Strings = getStrings(ctx.from.language_code); const lastUser = ctx.message.text.split(' ')[1]; @@ -72,7 +75,7 @@ export default (bot) => { return ctx.reply(Strings.lastFm.noUser, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -84,22 +87,24 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }); 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, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -124,7 +129,7 @@ export default (bot) => { return ctx.reply(noRecent, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -137,8 +142,8 @@ export default (bot) => { if (albumMbid) { imageUrl = await getFromMusicBrainz(albumMbid); - } - + } + if (!imageUrl) { imageUrl = getFromLast(track); } @@ -166,7 +171,7 @@ export default (bot) => { 'User-Agent': `@${botInfo.username}-node-telegram-bot` } }); - + num_plays = response_plays.data.track.userplaycount; } catch (err) { console.log(err) @@ -176,7 +181,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; @@ -200,13 +205,13 @@ export default (bot) => { caption: message, parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); } else { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; } catch (err) { @@ -217,7 +222,7 @@ export default (bot) => { ctx.reply(message, { parse_mode: "Markdown", disable_web_page_preview: true, - reply_to_message_id: ctx.message.message_id + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); }; }); diff --git a/telegram/commands/main.ts b/telegram/commands/main.ts new file mode 100755 index 0000000..9de0b69 --- /dev/null +++ b/telegram/commands/main.ts @@ -0,0 +1,556 @@ +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 * as schema from '../../database/schema'; +import { eq } from 'drizzle-orm'; +import { ensureUserInDb } from '../utils/ensure-user'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { getModelLabelByName } from './ai'; +import { models } from '../../config/ai'; +import { langs } from '../locales/config'; +import { modelPageSize, seriesPageSize } from '../../config/settings'; + +type UserRow = typeof schema.usersTable.$inferSelect; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +async function getUserAndStrings(ctx: Context, db: NodePgDatabase): Promise<{ user: UserRow | null, Strings: any, languageCode: string }> { + let user: UserRow | null = null; + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { user, Strings, languageCode }; + } + const { id, language_code } = ctx.from; + if (id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(id)), limit: 1 }); + if (dbUser.length === 0) { + await ensureUserInDb(ctx, db); + const newUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(id)), limit: 1 }); + if (newUser.length > 0) { + user = newUser[0]; + languageCode = user.languageCode; + } + } else { + user = dbUser[0]; + languageCode = user.languageCode; + } + } + if (!user && language_code) { + languageCode = language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', id); + } + const Strings = getStrings(languageCode); + return { user, Strings, languageCode }; +} + +type SettingsMenu = { text: string, reply_markup: any }; +function getSettingsMenu(user: UserRow, Strings: any): SettingsMenu { + const langObj = langs.find(l => l.code === user.languageCode); + const langLabel = langObj ? langObj.label : user.languageCode; + const userId = user.telegramId; + return { + text: `*${Strings.settings.selectSetting}*`, + reply_markup: { + inline_keyboard: [ + [ + { text: `✨ ${Strings.settings.ai.aiEnabled}: ${user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled}`, callback_data: `settings_aiEnabled_${userId}` }, + { text: `🧠 ${Strings.settings.ai.aiModel}: ${getModelLabelByName(user.customAiModel)}`, callback_data: `settings_aiModel_0_${userId}` } + ], + [ + { text: `🌡️ ${Strings.settings.ai.aiTemperature}: ${user.aiTemperature}`, callback_data: `settings_aiTemperature_${userId}` }, + { text: `🌐 ${langLabel}`, callback_data: `settings_language_${userId}` } + ], + [ + { text: `🧠 ${Strings.settings.ai.showThinking}: ${user.showThinking ? Strings.settings.enabled : Strings.settings.disabled}`, callback_data: `settings_showThinking_${userId}` } + ] + ] + } + }; +} + +function extractUserIdFromCallback(data: string): string | null { + const match = data.match(/_(\d+)$/); + return match ? match[1] : null; +} + +function getNotAllowedMessage(Strings: any) { + return Strings.gsmarenaNotAllowed; +} + +function logSettingsAccess(action: string, ctx: Context, allowed: boolean, expectedUserId: string | null) { + if (process.env.longerLogs === 'true') { + const actualUserId = ctx.from?.id; + const username = ctx.from?.username || ctx.from?.first_name || 'unknown'; + console.log(`[Settings] Action: ${action}, Callback from: ${username} (${actualUserId}), Expected: ${expectedUserId}, Allowed: ${allowed}`); + } +} + +function handleTelegramError(err: any, context: string) { + const description = err?.response?.description || ''; + const ignoredErrors = [ + 'query is too old', + 'query ID is invalid', + 'message is not modified', + 'message to edit not found', + ]; + + const isIgnored = ignoredErrors.some(errorString => description.includes(errorString)); + + if (!isIgnored) { + console.error(`[${context}] Unexpected Telegram error:`, err); + } +} + +export default (bot: Telegraf, db: NodePgDatabase) => { + bot.start(spamwatchMiddleware, async (ctx: Context) => { + const { user, Strings } = await getUserAndStrings(ctx, db); + const botInfo = await ctx.telegram.getMe(); + const reply_to_message_id = replyToMessageId(ctx); + const startMsg = Strings.botWelcome.replace(/{botName}/g, botInfo.first_name); + if (!user) return; + ctx.reply( + startMsg.replace( + /{aiEnabled}/g, + user.aiEnabled ? Strings.settings.enabled : Strings.settings.disabled + ).replace( + /{aiModel}/g, + getModelLabelByName(user.customAiModel) + ).replace( + /{aiTemperature}/g, + user.aiTemperature.toString() + ).replace( + /{aiRequests}/g, + user.aiRequests.toString() + ).replace( + /{aiCharacters}/g, + user.aiCharacters.toString() + ).replace( + /{languageCode}/g, + user.languageCode + ), { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + } + ); + }); + + bot.command(["settings"], spamwatchMiddleware, async (ctx: Context) => { + const reply_to_message_id = replyToMessageId(ctx); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const menu = getSettingsMenu(user, Strings); + await ctx.reply( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + } + ); + }); + + const updateSettingsKeyboard = async (ctx: Context, user: UserRow, Strings: any) => { + const menu = getSettingsMenu(user, Strings); + await ctx.editMessageReplyMarkup(menu.reply_markup); + }; + + bot.action(/^settings_aiEnabled_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settings_aiEnabled', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + await db.update(schema.usersTable) + .set({ aiEnabled: !user.aiEnabled }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + }); + + bot.action(/^settings_showThinking_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settings_showThinking', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + await db.update(schema.usersTable) + .set({ showThinking: !user.showThinking }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + }); + + bot.action(/^settings_aiModel_(\d+)_(\d+)$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settings_aiModel', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + + const match = data.match(/^settings_aiModel_(\d+)_/); + if (!match) return; + + const page = parseInt(match[1], 10); + const pageSize = 4; + const start = page * pageSize; + const end = start + pageSize; + + const paginatedModels = models.slice(start, end); + + const buttons = paginatedModels.map((series, idx) => { + const originalIndex = start + idx; + const isSelected = series.models.some(m => m.name === user.customAiModel); + const label = isSelected ? `✅ ${series.label}` : series.label; + return { text: label, callback_data: `selectseries_${originalIndex}_0_${user.telegramId}` }; + }); + + const navigationButtons: any[] = []; + if (page > 0) { + navigationButtons.push({ text: Strings.varStrings.varLess, callback_data: `settings_aiModel_${page - 1}_${user.telegramId}` }); + } + if (end < models.length) { + navigationButtons.push({ text: Strings.varStrings.varMore, callback_data: `settings_aiModel_${page + 1}_${user.telegramId}` }); + } + + const keyboard: any[][] = []; + for (const button of buttons) { + keyboard.push([button]); + } + + if (navigationButtons.length > 0) { + keyboard.push(navigationButtons); + } + keyboard.push([{ text: `${Strings.varStrings.varBack}`, callback_data: `settings_back_${user.telegramId}` }]); + + try { + await ctx.editMessageText( + `${Strings.settings.ai.selectSeries}`, + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: keyboard + } + } + ); + } catch (err) { + handleTelegramError(err, 'settings_aiModel'); + } + }); + + bot.action(/^selectseries_\d+_\d+_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('selectseries', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const match = data.match(/^selectseries_(\d+)_(\d+)_(\d+)$/); + if (!match) return; + const seriesIdx = parseInt(match[1], 10); + const modelPage = parseInt(match[2], 10); + const series = models[seriesIdx]; + if (!series) return; + + const seriesPage = Math.floor(seriesIdx / seriesPageSize); + + const start = modelPage * modelPageSize; + const end = start + modelPageSize; + const paginatedSeriesModels = series.models.slice(start, end); + + const modelButtons = paginatedSeriesModels.map((m, idx) => { + const originalModelIndex = start + idx; + const isSelected = m.name === user.customAiModel; + const label = isSelected ? `✅ ${m.label}` : m.label; + return [{ text: `${label} (${m.parameterSize})`, callback_data: `setmodel_${seriesIdx}_${originalModelIndex}_${user.telegramId}` }]; + }); + + const navigationButtons: any[] = []; + if (modelPage > 0) { + navigationButtons.push({ text: Strings.varStrings.varLess, callback_data: `selectseries_${seriesIdx}_${modelPage - 1}_${user.telegramId}` }); + } + if (end < series.models.length) { + navigationButtons.push({ text: Strings.varStrings.varMore, callback_data: `selectseries_${seriesIdx}_${modelPage + 1}_${user.telegramId}` }); + } + + const keyboard: any[][] = [...modelButtons]; + if (navigationButtons.length > 0) { + keyboard.push(navigationButtons); + } + keyboard.push([{ text: `${Strings.varStrings.varBack}`, callback_data: `settings_aiModel_${seriesPage}_${user.telegramId}` }]); + const desc = user.languageCode === 'pt' ? series.descriptionPt : series.descriptionEn; + try { + await ctx.editMessageText( + `${Strings.settings.ai.seriesDescription.replace('{seriesDescription}', desc)}\n\n${Strings.settings.ai.selectParameterSize.replace('{seriesLabel}', series.label).replace(' [ & Uncensored ]', '')}\n\n${Strings.settings.ai.parameterSizeExplanation}`, + { + reply_markup: { + inline_keyboard: keyboard + } + } + ); + } catch (err) { + handleTelegramError(err, 'selectseries'); + } + }); + + bot.action(/^setmodel_\d+_\d+_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('setmodel', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const match = data.match(/^setmodel_(\d+)_(\d+)_\d+$/); + if (!match) return; + const seriesIdx = parseInt(match[1], 10); + const modelIdx = parseInt(match[2], 10); + const series = models[seriesIdx]; + const model = series?.models[modelIdx]; + if (!series || !model) return; + await db.update(schema.usersTable) + .set({ customAiModel: model.name }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + const menu = getSettingsMenu(updatedUser, Strings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + handleTelegramError(err, 'setmodel'); + } + }); + + bot.action(/^settings_aiTemperature_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settings_aiTemperature', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const temps = [0.2, 0.5, 0.7, 0.9, 1.2]; + try { + await ctx.editMessageText( + `${Strings.settings.ai.temperatureExplanation}\n\n${Strings.settings.ai.selectTemperature}`, + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: temps.map(t => [{ text: t.toString(), callback_data: `settemp_${t}_${user.telegramId}` }]) + .concat([ + [{ text: Strings.varStrings.varMore, callback_data: `show_more_temps_${user.telegramId}` }], + [ + { text: Strings.varStrings.varBack, callback_data: `settings_back_${user.telegramId}` } + ] + ]) + } + } + ); + } catch (err) { + handleTelegramError(err, 'settings_aiTemperature'); + } + }); + + bot.action(/^show_more_temps_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('show_more_temps', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const moreTemps = [1.4, 1.6, 1.8, 2.0]; + try { + await ctx.editMessageReplyMarkup({ + inline_keyboard: moreTemps.map(t => [{ text: `🔥 ${t}`, callback_data: `settemp_${t}_${user.telegramId}` }]) + .concat([ + [{ text: Strings.varStrings.varLess, callback_data: `settings_aiTemperature_${user.telegramId}` }], + [{ text: Strings.varStrings.varBack, callback_data: `settings_back_${user.telegramId}` }] + ]) + }); + } catch (err) { + handleTelegramError(err, 'show_more_temps'); + } + }); + + bot.action(/^settemp_.+_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settemp', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const temp = parseFloat(data.replace(/^settemp_/, '').replace(/_\d+$/, '')); + await db.update(schema.usersTable) + .set({ aiTemperature: temp }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + await updateSettingsKeyboard(ctx, updatedUser, Strings); + }); + + bot.action(/^settings_language_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settings_language', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + try { + await ctx.editMessageText( + Strings.settings.selectLanguage, + { + parse_mode: 'Markdown', + reply_markup: { + inline_keyboard: langs.map(l => [{ text: l.label, callback_data: `setlang_${l.code}_${user.telegramId}` }]).concat([[{ text: `${Strings.varStrings.varBack}`, callback_data: `settings_back_${user.telegramId}` }]]) + } + } + ); + } catch (err) { + handleTelegramError(err, 'settings_language'); + } + }); + + bot.action(/^settings_back_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('settings_back', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user, Strings } = await getUserAndStrings(ctx, db); + if (!user) return; + const menu = getSettingsMenu(user, Strings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + handleTelegramError(err, 'settings_back'); + } + }); + + bot.action(/^setlang_.+_\d+$/, async (ctx) => { + const data = (ctx.callbackQuery as any).data; + const userId = extractUserIdFromCallback(data); + const allowed = !!userId && String(ctx.from.id) === userId; + logSettingsAccess('setlang', ctx, allowed, userId); + if (!allowed) { + const { Strings } = await getUserAndStrings(ctx, db); + return ctx.answerCbQuery(getNotAllowedMessage(Strings), { show_alert: true }); + } + await ctx.answerCbQuery(); + const { user } = await getUserAndStrings(ctx, db); + if (!user) { + console.log('[Settings] No user found'); + return; + } + const lang = data.replace(/^setlang_/, '').replace(/_\d+$/, ''); + await db.update(schema.usersTable) + .set({ languageCode: lang }) + .where(eq(schema.usersTable.telegramId, String(user.telegramId))); + const updatedUser = (await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(user.telegramId)), limit: 1 }))[0]; + const updatedStrings = getStrings(updatedUser.languageCode); + const menu = getSettingsMenu(updatedUser, updatedStrings); + try { + if (ctx.callbackQuery.message) { + await ctx.editMessageText( + menu.text, + { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + } + ); + } else { + await ctx.reply(menu.text, { + reply_markup: menu.reply_markup, + parse_mode: 'Markdown' + }); + } + } catch (err) { + handleTelegramError(err, 'setlang'); + } + }); + + bot.command('privacy', spamwatchMiddleware, async (ctx: Context) => { + const { Strings } = await getUserAndStrings(ctx, db); + if (!ctx.from || !ctx.message) return; + const message = Strings.botPrivacy.replace("{botPrivacy}", process.env.botPrivacy ?? ""); + ctx.reply(message, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id + } as any); + }); +}; \ No newline at end of file diff --git a/src/commands/modarchive.ts b/telegram/commands/modarchive.ts old mode 100644 new mode 100755 similarity index 52% rename from src/commands/modarchive.ts rename to telegram/commands/modarchive.ts index 7d1489e..5d451a6 --- a/src/commands/modarchive.ts +++ b/telegram/commands/modarchive.ts @@ -8,6 +8,7 @@ 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); @@ -24,22 +25,17 @@ async function downloadModule(moduleId: string): Promise { method: 'GET', responseType: 'stream', }); - const disposition = response.headers['content-disposition']; let fileName = moduleId; - if (disposition && disposition.includes('filename=')) { fileName = disposition .split('filename=')[1] .split(';')[0] .replace(/['"]/g, ''); } - - const filePath = path.resolve(__dirname, fileName); - + const filePath = path.join(__dirname, fileName); const writer = fs.createWriteStream(filePath); response.data.pipe(writer); - return new Promise((resolve, reject) => { writer.on('finish', () => resolve({ filePath, fileName })); writer.on('error', reject); @@ -49,39 +45,44 @@ async function downloadModule(moduleId: string): Promise { } } -export default (bot: Telegraf) => { - bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { - const Strings = getStrings(languageCode(ctx)); - const reply_to_message_id = replyToMessageId(ctx); - const moduleId = ctx.message?.text.split(' ')[1]; - - if (Number.isNaN(moduleId) || null) { - return ctx.reply(Strings.maInvalidModule, { - parse_mode: "Markdown", - ...({ reply_to_message_id }) - }); - } - const numberRegex = /^\d+$/; - const isNumber = numberRegex.test(moduleId); - if (isNumber) { - const result = await downloadModule(moduleId); - if (result) { - const { filePath, fileName } = result; - const regexExtension = /\.\w+$/i; - const hasExtension = regexExtension.test(fileName); - if (hasExtension) { - await ctx.replyWithDocument({ source: filePath }, { - caption: fileName, - ...({ reply_to_message_id }) - }); - fs.unlinkSync(filePath); - return; - } - } - } +export const modarchiveHandler = async (ctx: Context) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + const moduleId = ctx.message && 'text' in ctx.message && typeof ctx.message.text === 'string' + ? ctx.message.text.split(' ')[1]?.trim() + : undefined; + if (!moduleId || !/^\d+$/.test(moduleId)) { return ctx.reply(Strings.maInvalidModule, { parse_mode: "Markdown", - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); + } + const result = await downloadModule(moduleId); + if (result) { + const { filePath, fileName } = result; + const regexExtension = /\.\w+$/i; + const hasExtension = regexExtension.test(fileName); + if (hasExtension) { + try { + await ctx.replyWithDocument({ source: filePath }, { + caption: fileName, + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } finally { + try { fs.unlinkSync(filePath); } catch (e) { /* ignore */ } + } + return; + } + } + return ctx.reply(Strings.maInvalidModule, { + parse_mode: "Markdown", + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +}; + +export default (bot: Telegraf, db) => { + bot.command(['modarchive', 'tma'], spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'modarchive')) return; + await modarchiveHandler(ctx); }); }; diff --git a/src/commands/ponyapi.ts b/telegram/commands/ponyapi.ts old mode 100644 new mode 100755 similarity index 66% rename from src/commands/ponyapi.ts rename to telegram/commands/ponyapi.ts index 2202949..2bbc841 --- a/src/commands/ponyapi.ts +++ b/telegram/commands/ponyapi.ts @@ -7,6 +7,7 @@ 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); @@ -53,25 +54,42 @@ function capitalizeFirstLetter(letter: string) { return letter.charAt(0).toUpperCase() + letter.slice(1); } -export default (bot: Telegraf) => { +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) => { 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); - - ctx.reply(Strings.ponyApi.helpDesc, { - parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) - }); + sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id); }); bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + 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 = ctx.message.text.split(' ').slice(1).join(' ').replace(" ", "+"); - const { noCharName } = Strings.ponyApi + const userInput = message.text.split(' ').slice(1).join(' ').trim().replace(/\s+/g, '+'); + const { noCharName } = Strings.ponyApi; - if (verifyInput(ctx, userInput, noCharName)) { - return; + if (verifyInput(ctx, userInput, noCharName)) return; + if (!userInput || /[^a-zA-Z\s]/.test(userInput) || userInput.length > 30) { + return sendReply(ctx, Strings.mlpInvalidCharacter, reply_to_message_id); } const capitalizedInput = capitalizeFirstLetter(userInput); @@ -79,65 +97,34 @@ export default (bot: Telegraf) => { try { const response = await axios(apiUrl); - const charactersArray: Character[] = []; - - if (Array.isArray(response.data.data)) { - response.data.data.forEach(character => { - let aliases: string[] = []; - if (character.alias) { - if (typeof character.alias === 'string') { - aliases.push(character.alias); - } else if (Array.isArray(character.alias)) { - aliases = aliases.concat(character.alias); - } - } - - charactersArray.push({ - id: character.id, - name: character.name, - alias: aliases.length > 0 ? aliases.join(', ') : Strings.varStrings.varNone, - url: character.url, - sex: character.sex, - residence: character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone, - occupation: character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone, - kind: character.kind ? character.kind.join(', ') : Strings.varStrings.varNone, - image: character.image - }); - }); - }; - - if (charactersArray.length > 0) { + const data = response.data.data; + if (Array.isArray(data) && data.length > 0) { + const character = data[0]; + const aliases = Array.isArray(character.alias) + ? character.alias.join(', ') + : character.alias || Strings.varStrings.varNone; const result = Strings.ponyApi.charRes - .replace("{id}", charactersArray[0].id) - .replace("{name}", charactersArray[0].name) - .replace("{alias}", charactersArray[0].alias) - .replace("{url}", charactersArray[0].url) - .replace("{sex}", charactersArray[0].sex) - .replace("{residence}", charactersArray[0].residence) - .replace("{occupation}", charactersArray[0].occupation) - .replace("{kind}", charactersArray[0].kind); - - ctx.replyWithPhoto(charactersArray[0].image[0], { - caption: `${result}`, - parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) - }); + .replace("{id}", character.id) + .replace("{name}", character.name) + .replace("{alias}", aliases) + .replace("{url}", character.url) + .replace("{sex}", character.sex) + .replace("{residence}", character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{occupation}", character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{kind}", Array.isArray(character.kind) ? character.kind.join(', ') : Strings.varStrings.varNone); + sendPhoto(ctx, character.image[0], result, reply_to_message_id); } else { - ctx.reply(Strings.ponyApi.noCharFound, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - }; - } catch (error) { - const message = Strings.ponyApi.apiErr.replace('{error}', error.message); - ctx.reply(message, { - parse_mode: 'Markdown', - ...({ reply_to_message_id }) - }); - }; + sendReply(ctx, Strings.ponyApi.noCharFound, reply_to_message_id); + } + } catch (error: any) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message || 'Unknown error'); + sendReply(ctx, message, reply_to_message_id); + } }); bot.command("mlpep", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + 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); @@ -148,6 +135,14 @@ export default (bot: Telegraf) => { 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 { @@ -188,26 +183,26 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(episodeArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { ctx.reply(Strings.ponyApi.noEpisodeFound, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); 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); @@ -218,6 +213,15 @@ export default (bot: Telegraf) => { 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 { @@ -263,21 +267,19 @@ export default (bot: Telegraf) => { ctx.replyWithPhoto(comicArray[0].image, { caption: `${result}`, parse_mode: 'Markdown', - ...({ reply_to_message_id, disable_web_page_preview: true }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); } else { ctx.reply(Strings.ponyApi.noComicFound, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; } catch (error) { const message = Strings.ponyApi.apiErr.replace('{error}', error.message); ctx.reply(message, { parse_mode: 'Markdown', - - ...({ reply_to_message_id }) + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) }); }; }); diff --git a/src/commands/quotes.ts b/telegram/commands/quotes.ts old mode 100644 new mode 100755 similarity index 100% rename from src/commands/quotes.ts rename to telegram/commands/quotes.ts diff --git a/telegram/commands/randompony.ts b/telegram/commands/randompony.ts new file mode 100755 index 0000000..4ace245 --- /dev/null +++ b/telegram/commands/randompony.ts @@ -0,0 +1,52 @@ +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'; +import { isCommandDisabled } from '../utils/check-command-disabled'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export const randomponyHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + ctx.reply(Strings.ponyApi.searching, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + try { + const response = await axios(Resources.randomPonyApi); + let tags: string[] = []; + + if (response.data.pony.tags) { + if (typeof response.data.pony.tags === 'string') { + tags.push(response.data.pony.tags); + } else if (Array.isArray(response.data.pony.tags)) { + tags = tags.concat(response.data.pony.tags); + } + } + + ctx.replyWithPhoto(response.data.pony.representations.full, { + caption: `${response.data.pony.sourceURL}\n\n${tags.length > 0 ? tags.join(', ') : ''}`, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export default (bot: Telegraf, db) => { + bot.command(["rpony", "randompony", "mlpart"], spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'random-pony')) return; + await randomponyHandler(ctx); + }); +} \ No newline at end of file diff --git a/src/commands/weather.ts b/telegram/commands/weather.ts old mode 100644 new mode 100755 similarity index 92% rename from src/commands/weather.ts rename to telegram/commands/weather.ts index f72c343..26a1b04 --- a/src/commands/weather.ts +++ b/telegram/commands/weather.ts @@ -9,6 +9,7 @@ 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); @@ -34,10 +35,12 @@ function getLocaleUnit(countryCode: string) { } } -export default (bot: Telegraf) => { - bot.command(['clima', 'weather'], spamwatchMiddleware, async (ctx) => { +export default (bot: Telegraf, db: any) => { + bot.command(['weather', 'clima'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'weather')) return; + 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/src/commands/wiki.ts b/telegram/commands/wiki.ts old mode 100644 new mode 100755 similarity index 100% rename from src/commands/wiki.ts rename to telegram/commands/wiki.ts diff --git a/src/commands/youtube.ts b/telegram/commands/youtube.ts old mode 100644 new mode 100755 similarity index 96% rename from src/commands/youtube.ts rename to telegram/commands/youtube.ts index 96d5d80..5b20029 --- a/src/commands/youtube.ts +++ b/telegram/commands/youtube.ts @@ -2,6 +2,7 @@ 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'; @@ -72,8 +73,10 @@ const isValidUrl = (url: string): boolean => { } }; -export default (bot) => { +export default (bot, db) => { 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; @@ -113,7 +116,7 @@ export default (bot) => { 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 src/props/cookies.txt --merge-output-format mp4 -o"; + cmdArgs = "--max-filesize 2G --no-playlist --cookies telegram/props/cookies.txt --merge-output-format mp4 -o"; } else { cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`; } diff --git a/telegram/locales/config.ts b/telegram/locales/config.ts new file mode 100755 index 0000000..7da7d37 --- /dev/null +++ b/telegram/locales/config.ts @@ -0,0 +1,4 @@ +export const langs = [ + { code: 'en', label: 'English' }, + { code: 'pt', label: 'Português' } +]; \ No newline at end of file diff --git a/telegram/locales/english.json b/telegram/locales/english.json new file mode 100755 index 0000000..a6d7575 --- /dev/null +++ b/telegram/locales/english.json @@ -0,0 +1,240 @@ +{ + "userNotFound": "User not found.", + "botWelcome": "*Hello! I'm {botName}!*\nI was made with love by some nerds who really love programming!\n\n*By using {botName}, you affirm that you have read to and agree with the privacy policy (/privacy). This helps you understand where your data goes when using this bot.*\n\nAlso, you can use /help to see the bot commands!", + "botHelp": "*Hey, I'm {botName}, a simple bot made entirely from scratch in Telegraf and Node.js by some nerds who really love programming.*\n\nCheck out the source code: [Click here to go to GitHub]({sourceLink})\n\nClick on the buttons below to see which commands you can use!\n", + "botPrivacy": "Check out [this link]({botPrivacy}) to read the bot's privacy policy.", + "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", + "varMore": "➡️ More", + "varLess": "➖ Less" + }, + "unexpectedErr": "An unexpected error occurred: {error}", + "errInvalidOption": "Whoops! Invalid option!", + "commandDisabled": "🚫 This command is currently disabled for your account.\n\nYou can enable it in the web interface: {frontUrl}", + "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\n- /settings: Show your user settings", + "usefulCommands": "🛠️ Useful Commands", + "usefulCommandsDesc": "🛠️ *Useful commands*\n\n- /chatinfo: Send information about the group\n- /userinfo: Send information about yourself\n- /d | /device ``: Search for a device on GSMArena and show its specs.\n/codename | /whatis ``: Shows what device is based on the codename. Example: `/codename begonia`\n- /weather | /clima ``: See weather status for a specific location.\n- /modarchive | /tma ``: Download a module from The Mod Archive.\n- /http ``: Send details about a specific HTTP code. Example: `/http 404`", + "funnyCommands": "😂 Funny Commands", + "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`", + "ai": { + "helpEntry": "✨ AI Commands", + "helpDesc": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI model\n- /think ``: Ask a thinking model about a question\n- /ai ``: Ask your custom-set AI model a question\n- /aistop: Stop your current AI request\n- /aistats: Show your AI usage stats", + "helpDescAdmin": "✨ *AI Commands*\n\n- /ask ``: Ask a question to an AI model\n- /think ``: Ask a thinking model about a question\n- /ai ``: Ask your custom-set AI model a question\n- /aistop: Stop your current AI request\n- /aistats: Show your AI usage stats\n\n*Admin Commands:*\n- /queue: List current AI queue\n- /qdel ``: Clear queue items for a user\n- /qlimit `` ``: Timeout user from AI commands\n- /setexec `` ``: Set max execution time for user\n- /rlimit ``: Remove all AI limits for user\n- /limits: List all current AI limits", + "disabled": "✨ AI features are currently disabled globally.", + "disabledForUser": "✨ AI features are disabled for your account. You can enable them with the /settings command.", + "pulling": "🔄 Model {model} not found locally, pulling...", + "askGenerating": "✨ Generating response with {model}...", + "askNoMessage": "✨ You need to ask me a question!", + "languageCode": "Language", + "thinking": "`🧠 Thinking...`", + "finishedThinking": "`🧠 Done thinking.`", + "urlWarning": "\n\n⚠️ Note: The model cannot access or visit links!", + "inQueue": "ℹ️ You are {position} in the queue.", + "queueFull": "🚫 You already have too many requests in the queue. Please wait for them to finish.", + "startingProcessing": "✨ Starting to process your request...", + "systemPrompt": "You are a friendly assistant called {botName}.\nCurrent Date/Time (UTC): {date}\n\n---\n\nUser message:\n{message}", + "statusWaitingRender": "⏳ Streaming...", + "statusRendering": "🖼️ Rendering...", + "statusComplete": "✅ Complete!", + "modelHeader": "🤖 *{model}* 🌡️ *{temperature}* {status}", + "noChatFound": "No chat found", + "pulled": "✅ Pulled {model} successfully, please retry the command.", + "selectTemperature": "*Please select a temperature:*", + "temperatureExplanation": "Temperature controls the randomness of the AI's responses. Lower values (e.g., 0.2) make the model more focused and deterministic, while higher values (e.g., 1.2 or above) make it more creative and random.", + "queueEmpty": "✅ The AI queue is currently empty.", + "queueList": "📋 *AI Queue Status*\n\n{queueItems}\n\n*Total items:* {totalItems}", + "queueItem": "• User: {username} ({userId})\n Model: {model}\n Status: {status}\n", + "queueCleared": "✅ Cleared {count} queue items for user {userId}.", + "queueClearError": "❌ Error clearing queue for user {userId}: {error}", + "noQueueItems": "ℹ️ No queue items found for user {userId}.", + "userTimedOut": "⏱️ User {userId} has been timed out from AI commands until {timeoutEnd}.", + "userTimeoutRemoved": "✅ AI timeout removed for user {userId}.", + "userTimeoutError": "❌ Error setting timeout for user {userId}: {error}", + "invalidDuration": "❌ Invalid duration format. Use: 1m, 1h, 1d, 1w, etc.", + "userExecTimeSet": "⏱️ Max execution time set to {duration} for user {userId}.", + "userExecTimeRemoved": "✅ Max execution time limit removed for user {userId}.", + "userExecTimeError": "❌ Error setting execution time for user {userId}: {error}", + "invalidUserId": "❌ Invalid user ID. Please provide a valid Telegram user ID.", + "userNotFound": "❌ User {userId} not found in database.", + "userTimedOutFromAI": "⏱️ You are currently timed out from AI commands until {timeoutEnd}.", + "requestTooLong": "⏱️ Your request is taking too long. It has been cancelled to prevent system overload.", + "userLimitsRemoved": "✅ All AI limits removed for user {userId}.", + "userLimitRemoveError": "❌ Error removing limits for user {userId}: {error}", + "limitsHeader": "📋 *Current AI Limits*", + "noLimitsSet": "✅ No AI limits are currently set.", + "timeoutLimitsHeader": "*🔒 Users with AI Timeouts:*", + "timeoutLimitItem": "• {displayName} ({userId}) - Until: {timeoutEnd}", + "execLimitsHeader": "*⏱️ Users with Execution Time Limits:*", + "execLimitItem": "• {displayName} ({userId}) - Max: {execTime}", + "limitsListError": "❌ Error retrieving limits: {error}", + "requestStopped": "🛑 Your AI request has been stopped.", + "requestRemovedFromQueue": "🛑 Your AI request has been removed from the queue.", + "noActiveRequest": "ℹ️ You don't have any active AI requests to stop.", + "executionTimeoutReached": "\n\n⏱️ Max execution time limit reached!", + "stoppedCurrentAndCleared": "🛑 Stopped current request and cleared {count} queued item(s) for user {userId}.", + "stoppedCurrentRequestOnly": "🛑 Stopped current request for user {userId} (no queued items found).", + "stoppedCurrentAndClearedQueue": "🛑 Stopped current request and cleared all queued items for user {userId}." + }, + "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 `