diff --git a/.dockerignore b/.dockerignore new file mode 100755 index 0000000..cfdf0f2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +webui/node_modules +npm-debug.log +.git +webui/.git +.gitignore +webui/.gitignore +.env* +webui/.env* +webui/.next +*.md +!README.md +ollama/ +db/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100755 index 0000000..db7a321 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# links for source and privacy +botPrivacy = "https://github.com/abocn/TelegramBot/blob/main/TERMS_OF_USE.md" +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 new file mode 100755 index 0000000..f5e9a17 --- /dev/null +++ b/.github/workflows/njsscan.yml @@ -0,0 +1,42 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# This workflow integrates njsscan with GitHub's Code Scanning feature +# nodejsscan is a static security code scanner that finds insecure code patterns in your Node.js applications + +name: njsscan sarif + +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] + schedule: + - cron: '26 1 * * 0' + +permissions: + contents: read + +jobs: + njsscan: + permissions: + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/upload-sarif to upload SARIF results + actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status + runs-on: ubuntu-latest + name: njsscan code scanning + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - name: nodejsscan scan + id: njsscan + uses: ajinabraham/njsscan-action@7237412fdd36af517e2745077cedbf9d6900d711 + with: + args: '. --sarif --output results.sarif || true' + - name: Upload njsscan report + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: results.sarif diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100755 index 0000000..0578dfd --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,26 @@ +# This workflow warns and then closes issues and PRs that have had no activity for a specified amount of time. +# +# You can adjust the behavior by modifying this file. +# For more information, see: +# https://github.com/actions/stale +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "0 0 1 2,4,6,8,10,12 *" + +jobs: + stale: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + + steps: + - uses: actions/stale@v9.0.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: "Stale issue message" + stale-pr-message: "Stale pull request message" + stale-issue-label: "no-issue-activity" + stale-pr-label: "no-pr-activity" diff --git a/.github/workflows/update-authors.yml b/.github/workflows/update-authors.yml new file mode 100755 index 0000000..ae7f058 --- /dev/null +++ b/.github/workflows/update-authors.yml @@ -0,0 +1,37 @@ +name: Update AUTHORS File + +on: + push: + branches: + - main + +jobs: + update-authors: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Generate AUTHORS file (Name format) + run: | + git log --format='%aN <%aE>' | sort -u > AUTHORS + + - name: Check if AUTHORS file changed + run: | + if git diff --quiet AUTHORS; then + echo "No changes in AUTHORS file." + exit 0 + fi + + - name: Commit and push changes + uses: EndBug/add-and-commit@v9.1.4 + with: + push: true + add: "AUTHORS" + default_author: github_actions + message: "Update AUTHORS file automatically" diff --git a/.gitignore b/.gitignore old mode 100644 new mode 100755 index 56c7261..dbea724 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,156 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +# Specific *.env -*.gpg -*.asc *.txt -node_modules -__pycache__ -!requirements.txt \ No newline at end of file +*.mp4 +lastfm.json +sw-blocklist.txt +package-lock.json +tmp/ + +# Executables +*.exe +yt-dlp +ffmpeg + +# Bun +bun.lock* + +# Ollama +ollama/ + +# Docker +docker-compose.yml + +# postgres +db/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100755 index 0000000..4a96795 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "telegram/spamwatch"] + path = telegram/spamwatch + url = https://github.com/ABOCN/TelegramBot-SpamWatch diff --git a/AUTHORS b/AUTHORS new file mode 100755 index 0000000..a36de5c --- /dev/null +++ b/AUTHORS @@ -0,0 +1,19 @@ +A Bunch of Computer Nerds +A Bunch of Computer Nerds +A Bunch of Computer Nerds +Aidan +Anonymous +Bjoern Kerler +DaviDev <97841570+DaviisDev@users.noreply.github.com> +Giovani Finazzi <53719063+GiovaniFZ@users.noreply.github.com> +GiovaniFZ +Lucas Gabriel <90426410+lucmsilva651@users.noreply.github.com> +Lucas Gabriel +Luquinhas +Luquinhas +Weblate Admin +dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> +fossabot +github-actions <41898282+github-actions[bot]@users.noreply.github.com> +lucmsilva651 +mthlma <156229140+mthlma@users.noreply.github.com> diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100755 index 0000000..e1a467d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +abocn@protonmail.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..f0d7341 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +FROM oven/bun + +# Install ffmpeg and other deps +RUN apt-get update && apt-get install -y \ + ffmpeg \ + git \ + supervisor \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /usr/src/app + +COPY package*.json ./ +RUN bun install + +COPY webui/package*.json ./webui/ +WORKDIR /usr/src/app/webui +RUN bun install + +WORKDIR /usr/src/app +COPY . . + +WORKDIR /usr/src/app/webui +RUN bun run build + +RUN chmod +x /usr/src/app/telegram/plugins/yt-dlp/yt-dlp + +COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf + +VOLUME /usr/src/app/.env + +EXPOSE 3000 + +ENV PYTHONUNBUFFERED=1 +ENV BUN_LOG_LEVEL=info + +CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] diff --git a/LICENSE b/LICENSE old mode 100644 new mode 100755 index 6ba2544..a0cab39 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,28 @@ -MIT License +BSD 3-Clause License -Copyright (c) 2024 Lucas Gabriel +Copyright (c) 2025, Lucas Gabriel -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md old mode 100644 new mode 100755 index e65cf1f..36ad1dc --- a/README.md +++ b/README.md @@ -1,37 +1,179 @@ -# Lynx (Node.js Telegram Bot) -Lynx is a a simple Telegram bot made in Node.js. - - You can find Lynx at [@LynxBR_bot](https://t.me/LynxBR_bot) on Telegram. +# Kowalski (Node.js Telegram Bot) -## Requirements - - A Telegram bot (create one at [@BotFather](https://t.me/botfather)) - - Node.js 20 LTS (or above) - - Python 3 (or above, for use with SpamWatch API) - - Python dependencies: use ``pip install -r requirements.txt`` - - Node.js dependencies: use ``npm install`` +[![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md) +[![GitHub License](https://img.shields.io/github/license/abocn/TelegramBot)](https://github.com/abocn/TelegramBot/blob/main/LICENSE) +[![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=fff)](https://www.typescriptlang.org) +[![CodeQL](https://github.com/abocn/TelegramBot/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/abocn/TelegramBot/actions/workflows/github-code-scanning/codeql) +[![Dependabot Updates](https://github.com/abocn/TelegramBot/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/abocn/TelegramBot/actions/workflows/dependabot/dependabot-updates) -## Run it yourself, develop or contribute with Lynx -First, [make a fork of this repo](https://github.com/lucmsilva651/lynx/fork), or clone it with +Kowalski is a a simple Telegram bot made in Node.js. + +- You can find Kowalski at [@KowalskiNodeBot](https://t.me/KowalskiNodeBot) on Telegram. + +## Self-host requirements + +> [!IMPORTANT] +> You will only need all of them if you are not running it dockerized. Read ["Running with Docker"](#running-with-docker) for more information. + +- [Bun](https://bun.sh) (latest is suggested) +- A Telegram bot (create one at [@BotFather](https://t.me/botfather)) +- FFmpeg (only for the `/yt` command) +- Docker and Docker Compose (only required for Docker setup) +- Postgres + +### AI Requirements + +- High-end CPU *or* GPU (~ 6GB vRAM) +- If using CPU, enough RAM to load the models (~6GB w/ defaults) + +## Running with Docker + +> [!IMPORTANT] +> Please complete the above steps to prepare your local copy for building. You do not need to install FFmpeg on your host system. + +--- + +> [!NOTE] +> Using the `-d` flag when running causes Kowalski to run in the background. If you're just playing around or testing, you may not want to use this flag. + +You can also run Kowalski using Docker, which simplifies the setup process. Make sure you have Docker and Docker Compose installed. + +### Using Docker Compose + +1. **Copy compose file** + + _Without AI (Ollama)_ + + ```bash + mv docker-compose.yml.example docker-compose.yml + ``` + + _With AI (Ollama)_ + + ```bash + mv docker-compose.yml.ai.example docker-compose.yml + ``` + +1. **Make sure to setup your `.env` file first!** + + In order to successfuly deploy Kowalski, you will need to edit both your `.env` file and enter matching values in `webui/.env`. + + > [!TIP] + > If you intend to setup AI, the defaults for Docker are already included (just uncomment) and don't need to be changed. + > + > Further setup may be needed for GPUs. See the Ollama documentation for more. + +1. **Run the container** + + ```bash + docker compose up -d + ``` + +### Using Docker Run + +If you prefer to use Docker directly, you can use these instructions instead. + +1. **Make sure to setup your `.env` file first!** + + In order to successfuly deploy Kowalski, you will need to edit both your `.env` file and enter matching values in `webui/.env`. + +1. **Build the image** + + ```bash + docker build -t kowalski . + ``` + +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 ``` -git clone https://github.com/lucmsilva651/lynx -``` -Next, go to the repository directory, create a ``config.env`` file and put the content below: -``` -TGBOT_TOKEN="0000000000:AAAaaAAaaaaAaAaaAAAaaaAaaaaAAAAAaaa" -TGBOT_ADMINS=[0000000000, 1111111111, 2222222222] -SW_KEY="aAaAAaaAAaAA_AAAAAaaAAaaAAaaAAAAAAaaAaaAaaAAaaAAaAaAAaaAAaaAAaAaA" -``` -- **TGBOT_TOKEN**: Put your bot token that you created at [@BotFather](https://t.me/botfather) at the variable ``TGBOT_TOKEN`` (as the example above). -- **TGBOT_ADMINS**: Put the ID of the people responsible for managing the bot (as the example above). They can use some administrative + exclusive commands on any group. -- **SW_KEY**: A API key to make a blocklist to banned SpamWatch users. You can refer to SpamWatch docs to create a API key for yourself. -After editing the file, save all changes and run the bot with ``npm start``. +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). -## Notes -- The source code itself of the bot is at ``src/main.js``, and the commands are in ``src/commands``. - - You can edit this file and the ``package.json`` file as your needs. -- The name of the command file will always be the command itself. - - Example: ``whois.js`` will always be ``/whois`` on Telegram. -- Also, to see your changes, please restart the bot before making a issue. +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 + +### YouTube Downloading + +**Q:** I get a "Permission denied (EACCES)" error in the console when running the `/yt` command + +**A:** Make sure `telegram/plugins/yt-dlp/yt-dlp` is executable. You can do this on Linux like so: + +```bash +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 + + + Profile pictures of Kowalski contributors + + +Made with [contrib.rocks](https://contrib.rocks). ## About/License -MIT - 2024 Lucas Gabriel (lucmsilva). + +BSD-3-Clause - 2024 Lucas Gabriel (lucmsilva). + +With some components under Unlicense. diff --git a/TERMS_OF_USE.md b/TERMS_OF_USE.md new file mode 100755 index 0000000..42fd53b --- /dev/null +++ b/TERMS_OF_USE.md @@ -0,0 +1,18 @@ +# Terms of Use + +By using Kowalski ([@KowalskiNodeBot](https://t.me/KowalskiNodeBot), you agree to the terms outlined below. If you do not agree with any of these terms, please discontinue use of the bot immediately. + +## 1. Blocklist System + +We reserve the right to block users from accessing the bot based on their behavior. Users who generate inappropriate content or misuse the bot will be permanently blocked by user ID. Attempts to circumvent a block by using alternative or secondary accounts will also result in those accounts being blocked. + +Additionally, Kowalski integrates with the SpamWatch API to automatically deny access to users banned by that system. If you are listed in SpamWatch, you will not be able to use the bot. + +## 2. Source Code + +The bot's source code is publicly available. You can review it at the Kowalski GitHub Repository: +[https://github.com/abocn/TelegramBot](https://github.com/abocn/TelegramBot) + +## 3. Changes to These Terms + +We may modify or update these Terms of Use at any time, with or without prior notice. Continued use of the bot constitutes acceptance of the latest version of the terms. diff --git a/blocklist.js b/blocklist.js deleted file mode 100644 index 057eed1..0000000 --- a/blocklist.js +++ /dev/null @@ -1,28 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const blocklistPath = path.join(__dirname, 'blocklist.txt'); - -let blocklist = []; - -const readBlocklist = () => { - try { - const data = fs.readFileSync(blocklistPath, 'utf8'); - blocklist = data.split('\n').map(id => id.trim()).filter(id => id !== ''); - } catch (error) { - if (error.code === 'ENOENT') { - console.log('WARN: Blocklist file not found. Creating a new one.'); - fs.writeFileSync(blocklistPath, ''); - } else { - console.error('WARN: Error reading blocklist:', error); - } - } -}; - -const isBlocked = (userId) => { - return blocklist.includes(String(userId)); -}; - -readBlocklist(); - -module.exports = { isBlocked }; diff --git a/commands/bam.js b/commands/bam.js deleted file mode 100644 index 9789e66..0000000 --- a/commands/bam.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - - const message = `O usuario foi bamido com sucesso`; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} \ No newline at end of file diff --git a/commands/chatinfo.js b/commands/chatinfo.js deleted file mode 100644 index f5be223..0000000 --- a/commands/chatinfo.js +++ /dev/null @@ -1,33 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - const chatName = msg.chat.title; - const chatHandle = msg.chat.username; - const isForum = msg.chat.is_forum; - let chatNameOutput = ""; - let chatHandleOutput = ""; - let isForumOutput = ""; - - if (isForum) { - isForumOutput = "*This chat is a forum (has topics enabled).*"; - } else { - isForumOutput = "*This chat is not a forum (doesn't have topics enabled).*"; - } - - if (chatHandle) { - chatHandleOutput = `*Chat handle:* \`@${chatHandle}`; - } else { - chatHandleOutput = `*Chat handle:* \`none (private group)\``; - } - - // if chatName returns undefined, the chat is not a group or channel - if (chatName) { - chatNameOutput = `*Chat name:* \`${chatName}\`\n${chatHandleOutput}\n*Chat ID:* \`${chatId}\`\n\n${isForumOutput}`; - } else { - chatNameOutput = "Whoops!\nThis command doesn't work in PM."; - } - - const message = chatNameOutput; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} diff --git a/commands/customize.js b/commands/customize.js deleted file mode 100644 index d0dd4c6..0000000 --- a/commands/customize.js +++ /dev/null @@ -1,53 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - - const opts = { - reply_to_message_id: msg.message_id, - reply_markup: { - resize_keyboard: true, - one_time_keyboard: true, - keyboard: [ - [{text: 'He/Him'}], - [{text: 'She/Her'}], - [{text: 'They/Them'}], - ], - }, - parse_mode: 'Markdown' - }; - - const message = "Select your pronouns:"; - const message2 = "You selected He/Him"; - const message3 = "You selected She/Her"; - const message4 = "You selected They/Them"; - - bot.sendMessage(chatId, message, opts) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); - - bot.onText('He/Him', (msg) => { - bot.sendMessage(msg.chat.id, message2, { - reply_markup: { - remove_keyboard: true - } - }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -}); - -bot.onText('She/Her', (msg) => { - bot.sendMessage(msg.chat.id, message3, { - reply_markup: { - remove_keyboard: true - } - }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -}); - -bot.onText('They/Them', (msg) => { - bot.sendMessage(msg.chat.id, message4, { - reply_markup: { - remove_keyboard: true - } - }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -}); - -} diff --git a/commands/furry.js b/commands/furry.js deleted file mode 100644 index 02e79c3..0000000 --- a/commands/furry.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - const userName = msg.from.first_name; - let isFurry = ""; - - function getRandomInt(max) { - return Math.floor(Math.random() * max); - } - - const randomValue = getRandomInt(2); - - if (randomValue === 0) { - isFurry = `*You (${userName}) are not a furry.*`; - } else { - isFurry = `*Yes, you (${userName}) are a furry.*`; - } - - const message = `${isFurry}`; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} diff --git a/commands/gay.js b/commands/gay.js deleted file mode 100644 index 16ab2f9..0000000 --- a/commands/gay.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - const userName = msg.from.first_name; - let isGay = ""; - - function getRandomInt(max) { - return Math.floor(Math.random() * max); - } - - const randomValue = getRandomInt(2); - - if (randomValue === 0) { - isGay = `*You (${userName}) are not gay.*`; - } else { - isGay = `*Yes, you (${userName}) are gay.*`; - } - - const message = `${isGay}`; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} diff --git a/commands/help.js b/commands/help.js deleted file mode 100644 index 722a810..0000000 --- a/commands/help.js +++ /dev/null @@ -1,22 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - const lynxFullPhoto = 'https://graph.org/file/a77382dab4d62ba626806.jpg'; - - const message = `*Hello! I'm Lynx!*\n\nI'm a simple bot made entirely from scratch in Node.js by Lucas Gabriel (lucmsilva).\n\n` + - `I am running on a *GitHub Codespaces* server, and sometimes may I am off, so please refrain from overusing or spamming the bot!\n\n` + - `*Some commands to test:* - • */chatinfo* - send some information about the group - • */customize* - customize your pronouns (WIP) - • */furry* - check if you are a furry - • */gay* - check if you are gay - • */help* - send this message - • */privacy* - read the Privacy Policy - • */random* - pick a random number between 0-10 - • */start* - start the bot - • */whois* - send some information about yourself\n\n` + - `*See my source code in:* [GitHub Repository](https://github.com/lucmsilva651/lynx)\n\n` + - `Thanks to all users, testers, contributors, and others. Without you, perhaps this bot wouldn't be possible ❤️`; - - bot.sendPhoto(chatId, lynxFullPhoto, { caption: message, parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} diff --git a/commands/privacy.js b/commands/privacy.js deleted file mode 100644 index 3a9f75f..0000000 --- a/commands/privacy.js +++ /dev/null @@ -1,44 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - - const message = `*Privacy Policy for Lynx Telegram Bot (@LynxBR_bot)*\n` + - `Before using, you will need to read the privacy policy ` + - `to understand where your data goes when using this bot. ` + - `If you don't agree with any of these terms, stop using ` + - `the bot.\n\n` + - `*1. Data Collection and Use*\n` + - `All text messages sent to the Lynx bot, along with ` + - `their respective identifiers (username and ID), are ` + - `collected in a secure environment for the developers. ` + - `This data is used solely for the purpose of improving ` + - `and debugging the bot and is retained for a period `+ - `of 60 days before being completely deleted. ` + - `Also, any messages sended on groups or channels that the ` + - `bot is present are not collected by privacy reasons.\n\n` + - `*2. Data Sharing*\n` + - `Message data, including text and identifiers, is not ` + - `shared with any companies or third-party entities.\n\n` + - `*3. Legal Compliance*\n` + - `In the event of legal action, data will be provided ` + - `in accordance with applicable laws and regulations.\n\n` + - `*4. User-Generated Content*\n` + - `We (the creators, developers, and hosts of the bot) ` + - `are not responsible for any content generated by users, ` + - `whether it is triggered by our bot or another.\n\n` + - `*5. Blocklist System*\n` + - `We have implemented a blocklist system via user ID. If ` + - `a user generates inappropriate content or misuses the bot, ` + - `they will be permanently blocked. If the use of alternative ` + - `or secondary accounts by a blocked user is detected, those ` + - `accounts will also be blocked.\n\n` + - `*6. Source Code*\n` + - `If you wish to review the source code, please visit the ` + - `[Lynx GitHub Repository](https://github.com/lucmsilva651/lynx/).\n\n` + - `*7. Terms Modification*\n` + - `These terms may be changed or invalidated at any time, with or without prior notice.\n\n` + - `*8. Immediate Cancellation of Terms*\n` + - `In case of usage block, as mentioned above, the terms will be immediately cancelled for the user.`; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown', disable_web_page_preview: true }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -}; diff --git a/commands/random.js b/commands/random.js deleted file mode 100644 index 05cf7f8..0000000 --- a/commands/random.js +++ /dev/null @@ -1,15 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - - function getRandomInt(max) { - return Math.floor(Math.random() * max); - } - - const randomValue = getRandomInt(11); - - const message = `*Generated value:* \`${randomValue}\``; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); - } - \ No newline at end of file diff --git a/commands/start.js b/commands/start.js deleted file mode 100644 index 7d3fb0e..0000000 --- a/commands/start.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - const lynxProfilePhoto = 'https://graph.org/file/10452df450f13ffb968c5.jpg'; - - const message = `*Hello! I am Lynx!*\nI was made with love by Lucas Gabriel (lucmsilva)!\n\n` + - `*Before using, you will need to read the privacy policy (/privacy) ` + - `to understand where your data goes when using this bot.*\n\n` + - `Also, you can use /help to show the bot commands!`; - - bot.sendPhoto(chatId, lynxProfilePhoto, { caption: message, parse_mode: 'Markdown' } ) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} diff --git a/commands/stats.js b/commands/stats.js deleted file mode 100644 index 6982494..0000000 --- a/commands/stats.js +++ /dev/null @@ -1,54 +0,0 @@ -const os = require('os'); - -module.exports = function (bot, msg) { - const chatId = msg.chat.id; - const botAdmin = process.env.TGBOT_ADMINS; - - function formatUptime(uptime) { - const hours = Math.floor(uptime / 3600); - const minutes = Math.floor((uptime % 3600) / 60); - const seconds = Math.floor(uptime % 60); - return `${hours}h ${minutes}m ${seconds}s`; - } - - function getSystemInfo() { - const platform = os.platform(); - const release = os.release(); - const arch = os.arch(); - const cpuModel = os.cpus()[0].model; - const cpuCores = os.cpus().length; - const totalMemory = (os.totalmem() / (1024 ** 3)).toFixed(2) + ' GB'; - const freeMemory = (os.freemem() / (1024 ** 3)).toFixed(2) + ' GB'; - const loadAverage = os.loadavg().map(avg => avg.toFixed(2)).join(', '); - const uptime = formatUptime(os.uptime()); - const nodeVersion = process.version; - const homeDir = os.homedir(); - const hostName = os.hostname(); - const tempDir = os.tmpdir(); - const userInfo = os.userInfo(); - - return `*Server Stats*\n\n` + - `*OS:* \`${platform} ${release}\`\n` + - `*Arch:* \`${arch}\`\n` + - `*Node.js Version:* \`${nodeVersion}\`\n` + - `*CPU:* \`${cpuModel}\`\n` + - `*CPU Cores:* \`${cpuCores} cores\`\n` + - `*RAM:* \`${freeMemory} / ${totalMemory}\`\n` + - `*Load Average:* \`${loadAverage}\`\n` + - `*Uptime:* \`${uptime}\`\n\n` + - `*Username*: \`${userInfo.username}\`\n` + - `*Hostname:* \`${hostName}\`\n` + - `*Home Directory:* \`${homeDir}\`\n` + - `*Temp. Directory:* \`${tempDir}\``; - } - - const message = getSystemInfo(); - - const isAdmin = botAdmin.includes(msg.from.id.toString()); - if (isAdmin) { - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); - } else { - return; - } -} diff --git a/commands/whois.js b/commands/whois.js deleted file mode 100644 index 14f1193..0000000 --- a/commands/whois.js +++ /dev/null @@ -1,28 +0,0 @@ -module.exports = function(bot, msg) { - const chatId = msg.chat.id; - const userName = msg.from.first_name; - const userId = msg.from.id; - const userHandle = msg.from.username; - const isBot = msg.from.is_bot; - const userPremium = msg.from.is_premium; - const userLang = msg.from.language_code; - let haveUsername = ""; - let userPremiumOutput = ""; - - if (userPremium) { - userPremiumOutput = "*You have a Telegram Premium subscription.*"; - } else { - userPremiumOutput = "*You don't have a Telegram Premium subscription.*"; - } - - if (userHandle) { - haveUsername = `*Your username is:* \`@${userHandle}\``; - } else { - haveUsername = `*Your username is:* \`none\``; - } - - const message = `*Your name is:* \`${userName}\`\n${haveUsername}\n*Your ID is:* \`${userId}\`\n*You are a bot:* \`${isBot}\`\n*Your language:* \`${userLang}\`\n\n${userPremiumOutput}`; - - bot.sendMessage(chatId, message, { parse_mode: 'Markdown' }) - .catch(error => console.error('WARN: Message cannot be sent: ', error)); -} 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.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/logger.js b/logger.js deleted file mode 100644 index 0fbb0ac..0000000 --- a/logger.js +++ /dev/null @@ -1,41 +0,0 @@ -const fs = require('fs'); -const util = require('util'); - -const logFile = 'log.txt'; -const logStream = fs.createWriteStream(logFile, { flags: 'a' }); - -const getFormattedDate = () => { - const date = new Date(); - const year = date.getFullYear(); - const month = ('0' + (date.getMonth() + 1)).slice(-2); - const day = ('0' + date.getDate()).slice(-2); - const hours = ('0' + date.getHours()).slice(-2); - const minutes = ('0' + date.getMinutes()).slice(-2); - const seconds = ('0' + date.getSeconds()).slice(-2); - return `[${year}-${month}-${day} ${hours}:${minutes}:${seconds}]`; -}; - -const logMessage = async (message) => { - const timestamp = getFormattedDate(); - const formattedMessage = `${timestamp} ${util.format(message)}`; - - process.stdout.write(formattedMessage + '\n'); - - return new Promise((resolve, reject) => { - logStream.write(formattedMessage + '\n', (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); -}; - -console.log = (message) => { - logMessage(message).catch(err => { - process.stderr.write(`WARN: Error writing to log: ${err}\n`); - }); -}; - -module.exports = logMessage; diff --git a/main.js b/main.js deleted file mode 100644 index f808247..0000000 --- a/main.js +++ /dev/null @@ -1,42 +0,0 @@ -const TelegramBot = require('node-telegram-bot-api'); -const fs = require('fs'); -const path = require('path'); -const token = process.env.TGBOT_TOKEN; -const bot = new TelegramBot(token, { polling: true }); -const { isBlocked } = require('./blocklist'); -const { isOnSpamWatch } = require('./spamwatch'); -require('./logger'); - -const commandsPath = path.join(__dirname, 'commands'); -const commandHandlers = {}; - -fs.readdirSync(commandsPath).forEach(file => { - const command = `/${path.parse(file).name}`; - const handler = require(path.join(commandsPath, file)); - commandHandlers[command] = handler; -}); - -bot.on('message', (msg) => { - const userName = msg.from.first_name; - const userId = msg.from.id; - const messageText = msg.text; - - if (msg.chat.type == 'private') { - if (isBlocked(userId) || isOnSpamWatch(userId)) { - console.log(`WARN: Blocked user ${userName}, ${userId} tried to access the bot with the command or message "${messageText}".\n`); - return; - } - console.log(`INFO: User ${userName}, ${userId} sended a command or message with the content: - • ${messageText}\n`) - } - - if (commandHandlers[messageText]) { - commandHandlers[messageText](bot, msg); - } -}); - -bot.on('polling_error', (error) => { - console.error('WARN: Polling error:', error); -}); - -console.log(`INFO: Lynx started\n`); diff --git a/nodemon.json b/nodemon.json new file mode 100755 index 0000000..d9938ac --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "ignore": ["telegram/props/*.json", "telegram/props/*.txt"], + "watch": ["telegram", "database", "config"], + "ext": "ts,js", + "exec": "bun telegram/bot.ts" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 38eeb13..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2048 +0,0 @@ -{ - "name": "lynx", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lynx", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "node-telegram-bot-api": "^0.66.0", - "nodemon": "^3.1.4" - } - }, - "node_modules/@cypress/request": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", - "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "http-signature": "~1.3.6", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "performance-now": "^2.1.0", - "qs": "6.10.4", - "safe-buffer": "^5.1.2", - "tough-cookie": "^4.1.3", - "tunnel-agent": "^0.6.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/@cypress/request-promise": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@cypress/request-promise/-/request-promise-5.0.0.tgz", - "integrity": "sha512-eKdYVpa9cBEw2kTBlHeu1PP16Blwtum6QHg/u9s/MoHkZfuo1pRGka1VlUHXF5kdew82BvOJVVGk0x8X0nbp+w==", - "dependencies": { - "bluebird": "^3.5.0", - "request-promise-core": "1.1.3", - "stealthy-require": "^1.1.1", - "tough-cookie": "^4.1.3" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "@cypress/request": "^3.0.0" - } - }, - "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "peer": true, - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", - "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", - "dependencies": { - "call-bind": "^1.0.5", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findindex": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/array.prototype.findindex/-/array.prototype.findindex-2.2.3.tgz", - "integrity": "sha512-Saz3pStJ2X5bg27GTWWLyhJrcwbMVLsnbho2zUVQFW2Pgrh0mSKKvAeZr6BPww7E1AygK33cX7w0W1YERC1RHA==", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", - "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.2.1", - "get-intrinsic": "^1.2.3", - "is-array-buffer": "^3.0.4", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.13.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.0.tgz", - "integrity": "sha512-3AungXC4I8kKsS9PuS4JH2nc+0bVY/mjgrephHTIi8fpEeGsTHBUJeosp0Wc1myYMElmD0B3Oc4XL/HVJ4PV2g==" - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bl": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", - "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", - "dependencies": { - "readable-stream": "^2.3.5", - "safe-buffer": "^5.1.1" - } - }, - "node_modules/bluebird": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", - "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==" - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dependencies": { - "assert-plus": "^1.0.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", - "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", - "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", - "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/es-abstract": { - "version": "1.23.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", - "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "arraybuffer.prototype.slice": "^1.0.3", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "data-view-buffer": "^1.0.1", - "data-view-byte-length": "^1.0.1", - "data-view-byte-offset": "^1.0.0", - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-set-tostringtag": "^2.0.3", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.4", - "get-symbol-description": "^1.0.2", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", - "has-symbols": "^1.0.3", - "hasown": "^2.0.2", - "internal-slot": "^1.0.7", - "is-array-buffer": "^3.0.4", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.1", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.3", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.13", - "is-weakref": "^1.0.2", - "object-inspect": "^1.13.1", - "object-keys": "^1.1.1", - "object.assign": "^4.1.5", - "regexp.prototype.flags": "^1.5.2", - "safe-array-concat": "^1.1.2", - "safe-regex-test": "^1.0.3", - "string.prototype.trim": "^1.2.9", - "string.prototype.trimend": "^1.0.8", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.2", - "typed-array-byte-length": "^1.0.1", - "typed-array-byte-offset": "^1.0.2", - "typed-array-length": "^1.0.6", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.15" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dependencies": { - "get-intrinsic": "^1.2.4", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", - "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", - "dependencies": { - "hasown": "^2.0.0" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eventemitter3": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz", - "integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==" - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" - }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "engines": [ - "node >=0.6.0" - ] - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "peer": true - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "peer": true - }, - "node_modules/file-type": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", - "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "engines": { - "node": "*" - } - }, - "node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", - "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", - "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", - "dependencies": { - "call-bind": "^1.0.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dependencies": { - "assert-plus": "^1.0.0" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "peer": true, - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-signature": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz", - "integrity": "sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==", - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^2.0.2", - "sshpk": "^1.14.1" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==" - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", - "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", - "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", - "dependencies": { - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", - "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", - "dependencies": { - "call-bind": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", - "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", - "dependencies": { - "which-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==" - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==" - }, - "node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==" - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "peer": true - }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" - }, - "node_modules/jsprim": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", - "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" - }, - "node_modules/node-telegram-bot-api": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/node-telegram-bot-api/-/node-telegram-bot-api-0.66.0.tgz", - "integrity": "sha512-s4Hrg5q+VPl4/tJVG++pImxF6eb8tNJNj4KnDqAOKL6zGU34lo9RXmyAN158njwGN+v8hdNf8s9fWIYW9hPb5A==", - "dependencies": { - "@cypress/request": "^3.0.1", - "@cypress/request-promise": "^5.0.0", - "array.prototype.findindex": "^2.0.2", - "bl": "^1.2.3", - "debug": "^3.2.7", - "eventemitter3": "^3.0.0", - "file-type": "^3.9.0", - "mime": "^1.6.0", - "pump": "^2.0.0" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/nodemon": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", - "integrity": "sha512-wjPBbFhtpJwmIeY2yP7QF+UKzPfltVGtfce1g/bB15/8vCGZj8uxD62b/b9M9/WVgme0NZudpownKN+c0plXlQ==", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nodemon/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "peer": true, - "engines": { - "node": "*" - } - }, - "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", - "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", - "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" - }, - "node_modules/psl": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", - "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==" - }, - "node_modules/pump": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", - "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/qs": { - "version": "6.10.4", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.4.tgz", - "integrity": "sha512-OQiU+C+Ds5qiH91qh/mg0w+8nwQuLjM4F4M/PbmhDOoYehPh+Fb0bDjtR1sOvy7YKxvj28Y/M0PhP5uVX0kB+g==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", - "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", - "dependencies": { - "call-bind": "^1.0.6", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "set-function-name": "^2.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "peer": true, - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/request-promise-core": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", - "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", - "dependencies": { - "lodash": "^4.17.15" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "request": "^2.34" - } - }, - "node_modules/request/node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", - "peer": true, - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, - "node_modules/request/node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "peer": true, - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "peer": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/request/node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "peer": true, - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/request/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "peer": true, - "bin": { - "uuid": "bin/uuid" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" - }, - "node_modules/safe-array-concat": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", - "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", - "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/safe-regex-test": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", - "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", - "dependencies": { - "call-bind": "^1.0.6", - "es-errors": "^1.3.0", - "is-regex": "^1.1.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" - }, - "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" - }, - "node_modules/string.prototype.trim": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", - "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", - "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/tough-cookie": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", - "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", - "dependencies": { - "psl": "^1.1.33", - "punycode": "^2.1.1", - "universalify": "^0.2.0", - "url-parse": "^1.5.3" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" - }, - "node_modules/typed-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", - "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", - "dependencies": { - "call-bind": "^1.0.7", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", - "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", - "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", - "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-proto": "^1.0.3", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==" - }, - "node_modules/universalify": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", - "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "peer": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", - "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - } - } -} diff --git a/package.json b/package.json old mode 100644 new mode 100755 index 58a4ef1..b5c33cc --- a/package.json +++ b/package.json @@ -1,29 +1,25 @@ { - "name": "lynx", - "version": "1.0.0", - "description": "A simple Telegram bot made in Node.js", - "main": "src/main.js", "scripts": { - "beta": "nodemon --env-file=beta.env main.js", - "start": "nodemon --env-file=config.env main.js" + "start": "nodemon telegram/bot.ts", + "docs": "bunx typedoc", + "serve:docs": "bun run serve-docs.ts" }, - "repository": { - "type": "git", - "url": "git+https://github.com/lucmsilva651/lynx.git" - }, - "keywords": [ - "node", - "telegram", - "telegram-bot" - ], - "author": "Lucas Gabriel (lucmsilva)", - "license": "MIT", - "bugs": { - "url": "https://github.com/lucmsilva651/lynx/issues" - }, - "homepage": "https://github.com/lucmsilva651/lynx#readme", "dependencies": { - "node-telegram-bot-api": "^0.66.0", - "nodemon": "^3.1.4" + "@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/requirements.txt b/requirements.txt deleted file mode 100644 index 989a366..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -python-dotenv -spamwatch \ No newline at end of file diff --git a/spamwatch.js b/spamwatch.js deleted file mode 100644 index 961b354..0000000 --- a/spamwatch.js +++ /dev/null @@ -1,28 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const blocklistPath = path.join(__dirname, 'sw_blocklist.txt'); - -let blocklist = []; - -const readBlocklist = () => { - try { - const data = fs.readFileSync(blocklistPath, 'utf8'); - blocklist = data.split('\n').map(id => id.trim()).filter(id => id !== ''); - } catch (error) { - if (error.code === 'ENOENT') { - console.log('WARN: SpamWatch blocklist file not found. Creating a new, blank one.'); - fs.writeFileSync(blocklistPath, ''); - } else { - console.error('WARN: Error reading SpamWatch blocklist:', error); - } - } -}; - -const isOnSpamWatch = (userId) => { - return blocklist.includes(String(userId)); -}; - -readBlocklist(); - -module.exports = { isOnSpamWatch }; diff --git a/start-services.sh b/start-services.sh new file mode 100644 index 0000000..91243dc --- /dev/null +++ b/start-services.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +echo "Starting BOT..." +cd /usr/src/app +bun start 2>&1 | sed "s/^/[BOT] /" & +BOT_PID=$! +echo "BOT started with PID $BOT_PID" + +echo "Starting WEBUI..." +cd /usr/src/app/webui +bun run start 2>&1 | sed "s/^/[WEBUI] /" & +WEBUI_PID=$! +echo "WEBUI started with PID $WEBUI_PID" + +echo "Services started:" +echo " Bot PID: $BOT_PID" +echo " WebUI PID: $WEBUI_PID" + +wait $BOT_PID $WEBUI_PID \ No newline at end of file diff --git a/supervisord.conf b/supervisord.conf new file mode 100644 index 0000000..ca1704e --- /dev/null +++ b/supervisord.conf @@ -0,0 +1,31 @@ +[supervisord] +nodaemon=true +user=root +logfile=/dev/stdout +logfile_maxbytes=0 +pidfile=/var/run/supervisord.pid +loglevel=info + +[program:telegram-bot] +command=bun start +directory=/usr/src/app +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stdout_logfile_backups=0 +stderr_logfile_backups=0 + +[program:webui] +command=bun run start +directory=/usr/src/app/webui +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +stdout_logfile_backups=0 +stderr_logfile_backups=0 \ No newline at end of file diff --git a/sw_api.py b/sw_api.py deleted file mode 100644 index 105e5b0..0000000 --- a/sw_api.py +++ /dev/null @@ -1,12 +0,0 @@ -import os -from dotenv import load_dotenv -import spamwatch - -load_dotenv("config.env") - -client = spamwatch.Client(os.getenv('SW_KEY')) -bans = client.get_bans_min() - -with open('sw_blocklist.txt', 'w') as file: - for ban in bans: - file.write(f'{ban}\n') diff --git a/telegram/api/server.ts b/telegram/api/server.ts new file mode 100755 index 0000000..3bf1154 --- /dev/null +++ b/telegram/api/server.ts @@ -0,0 +1,102 @@ +import express from "express"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Client } from "pg"; +import * as schema from "../../database/schema"; +import { eq } from "drizzle-orm"; +import { twoFactorTable, usersTable } from "../../database/schema"; +import { Telegraf } from "telegraf"; +import { getStrings } from "../plugins/checklang"; + +const client = new Client({ connectionString: process.env.databaseUrl }); +const db = drizzle(client, { schema }); + +const bot = new Telegraf(process.env.botToken!); +const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" + +function shouldLogLonger() { + return process.env.longerLogs === 'true'; +} + +export async function startServer() { + await client.connect(); + + const app = express(); + + app.use(express.json()); + + app.get("/health", (res) => { + res.send("OK"); + }); + + app.post("/2fa/get", async (req, res) => { + try { + const { userId } = req.body; + + if (!userId) { + console.log("[🌐 API] Missing userId in request"); + return res.status(400).json({ generated: false, error: "User ID is required" }); + } + + if (shouldLogLonger()) { + console.log("[🌐 API] Looking up user:", userId); + } + const user = await db.query.usersTable.findFirst({ + where: eq(usersTable.telegramId, userId), + columns: { + languageCode: true, + }, + }); + + if (!user) { + console.log("[🌐 API] User not found:", userId); + return res.status(404).json({ generated: false, error: "User not found" }); + } + + const code = Math.floor(100000 + Math.random() * 900000).toString(); + + console.log("[🌐 API] Inserting 2FA record"); + + await db.insert(twoFactorTable).values({ + userId, + currentCode: code, + codeAttempts: 0, + codeExpiresAt: new Date(Date.now() + 1000 * 60 * 5), + }).onConflictDoUpdate({ + target: twoFactorTable.userId, + set: { + currentCode: code, + codeAttempts: 0, + codeExpiresAt: new Date(Date.now() + 1000 * 60 * 5), + } + }); + + if (shouldLogLonger()) { + console.log("[🌐 API] Sending 2FA message"); + } + + try { + const Strings = getStrings(user.languageCode); + const message = Strings.twoFactor.codeMessage + .replace("{botName}", botName) + .replace("{code}", code); + await bot.telegram.sendMessage(userId, message, { parse_mode: "MarkdownV2" }); + if (shouldLogLonger()) { + console.log("[🌐 API] Message sent successfully"); + } + } catch (error) { + console.error("[🌐 API] Error sending 2FA code to user", error); + return res.status(500).json({ generated: false, error: "Error sending 2FA message" }); + } + + res.json({ generated: true }); + + } catch (error) { + console.error("[🌐 API] Unexpected error in 2FA endpoint:", error); + return res.status(500).json({ generated: false, error: "Internal server error" }); + } + }); + + app.listen(3030, () => { + console.log("[🌐 API] Running on port 3030\n"); + }); +} \ No newline at end of file diff --git a/telegram/bot.ts b/telegram/bot.ts new file mode 100755 index 0000000..c75d33a --- /dev/null +++ b/telegram/bot.ts @@ -0,0 +1,131 @@ +import { Telegraf } from 'telegraf'; +import path from 'path'; +import fs from 'fs'; +import { isSpamwatchConnected } from './spamwatch/spamwatch'; +import '@dotenvx/dotenvx'; +import 'dotenv/config'; +import './plugins/ytDlpWrapper'; +import { preChecks } from './commands/ai'; +import { drizzle } from 'drizzle-orm/node-postgres'; +import { Client } from 'pg'; +import * as schema from '../database/schema'; +import { ensureUserInDb } from './utils/ensure-user'; +import { getSpamwatchBlockedCount } from './spamwatch/spamwatch'; +import { startServer } from './api/server'; + +(async function main() { + const { botToken, handlerTimeout, maxRetries, databaseUrl, ollamaEnabled } = process.env; + if (!botToken || botToken === 'InsertYourBotTokenHere') { + console.error('Bot token is not set. Please set the bot token in the .env file.'); + process.exit(1); + } + + if (ollamaEnabled === "true") { + if (!(await preChecks())) { + process.exit(1); + } + } + + const client = new Client({ connectionString: databaseUrl }); + await client.connect(); + const db = drizzle(client, { schema }); + + const bot = new Telegraf( + botToken, + { handlerTimeout: Number(handlerTimeout) || 600_000 } + ); + const maxRetriesNum = Number(maxRetries) || 5; + let restartCount = 0; + + bot.use(async (ctx, next) => { + await ensureUserInDb(ctx, db); + return next(); + }); + + function loadCommands() { + const commandsPath = path.join(__dirname, 'commands'); + let loadedCount = 0; + try { + const files = fs.readdirSync(commandsPath) + .filter(file => file.endsWith('.ts')); + files.forEach((file) => { + try { + const commandPath = path.join(commandsPath, file); + const command = require(commandPath).default || require(commandPath); + if (typeof command === 'function') { + command(bot, db); + loadedCount++; + } + } catch (error) { + console.error(`Failed to load command file ${file}: ${error.message}`); + } + }); + console.log(`[🤖 BOT] Loaded ${loadedCount} commands.`); + } catch (error) { + console.error(`Failed to read commands directory: ${error.message}`); + } + } + + async function startBot() { + try { + const botInfo = await bot.telegram.getMe(); + console.log(`${botInfo.first_name} is running...`); + await bot.launch(); + restartCount = 0; + } catch (error) { + console.error('Failed to start bot:', error.message); + if (restartCount < maxRetriesNum) { + restartCount++; + console.log(`Retrying to start bot... Attempt ${restartCount}`); + setTimeout(startBot, 5000); + } else { + console.error('Maximum retry attempts reached. Exiting.'); + process.exit(1); + } + } + } + + function handleShutdown(signal: string) { + 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); + }); + + async function testDbConnection() { + try { + await db.query.usersTable.findMany({ limit: 1 }); + const users = await db.query.usersTable.findMany({}); + const userCount = users.length; + console.log(`[💽 DB] Connected [${userCount} users]`); + } catch (err) { + console.error('[💽 DB] Failed to connect:', err); + process.exit(1); + } + } + + await testDbConnection(); + + if (isSpamwatchConnected()) { + const blockedCount = getSpamwatchBlockedCount(); + // the 3 spaces are intentional + console.log(`[🛡️ SW] Connected [${blockedCount} blocked]`); + } else { + console.log('[🛡️ SW] Not connected or blocklist empty'); + } + + loadCommands(); + startServer(); + startBot(); +})(); diff --git a/telegram/commands/ai.ts b/telegram/commands/ai.ts new file mode 100755 index 0000000..87ff59c --- /dev/null +++ b/telegram/commands/ai.ts @@ -0,0 +1,1316 @@ +// AI.TS +// by ihatenodejs/Aidan +// +// ----------------------------------------------------------------------- +// +// This is free and unencumbered software released into the public domain. +// +// Anyone is free to copy, modify, publish, use, compile, sell, or +// distribute this software, either in source code form or as a compiled +// binary, for any purpose, commercial or non-commercial, and by any +// means. +// +// In jurisdictions that recognize copyright laws, the author or authors +// of this software dedicate any and all copyright interest in the +// software to the public domain. We make this dedication for the benefit +// of the public at large and to the detriment of our heirs and +// successors. We intend this dedication to be an overt act of +// relinquishment in perpetuity of all present and future rights to this +// software under copyright law. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +// OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// For more information, please refer to + +import { isOnSpamWatch } from "../spamwatch/spamwatch" +import spamwatchMiddlewareModule from "../spamwatch/Middleware" +import { Telegraf, Context } from "telegraf" +import type { Message } from "telegraf/types" +import { replyToMessageId } from "../utils/reply-to-message-id" +import { getStrings } from "../plugins/checklang" +import axios from "axios" +import { rateLimiter } from "../utils/rate-limiter" +import { logger } from "../utils/log" +import { ensureUserInDb } from "../utils/ensure-user" +import * as schema from '../../database/schema' +import type { NodePgDatabase } from "drizzle-orm/node-postgres" +import { eq, sql, and, gt, isNotNull } from 'drizzle-orm' +import { models, unloadModelAfterB, maxUserQueueSize } from "../../config/ai" +import { isCommandDisabled } from "../utils/check-command-disabled" + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch) +export const flash_model = process.env.flashModel || "gemma3:4b" +export const thinking_model = process.env.thinkingModel || "qwen3:4b" + +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); +} + +function parseDuration(duration: string): number { + const match = duration.match(/^(\d+)([smhdw])$/); + if (!match) return -1; + + const value = parseInt(match[1]); + const unit = match[2]; + + switch (unit) { + case 's': return value; + case 'm': return value * 60; + case 'h': return value * 60 * 60; + case 'd': return value * 60 * 60 * 24; + case 'w': return value * 60 * 60 * 24 * 7; + default: return -1; + } +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + if (seconds < 604800) return `${Math.floor(seconds / 86400)}d`; + return `${Math.floor(seconds / 604800)}w`; +} + +async function checkUserTimeout(ctx: Context, db: NodePgDatabase, userId: string, Strings: ReturnType): Promise { + const user = await db.query.usersTable.findFirst({ where: (fields, { eq }) => eq(fields.telegramId, userId) }); + if (!user) return false; + + if (user.aiTimeoutUntil && user.aiTimeoutUntil > new Date()) { + const timeoutEnd = user.aiTimeoutUntil.toISOString(); + const reply_to_message_id = replyToMessageId(ctx); + await ctx.reply(Strings.ai.userTimedOutFromAI.replace("{timeoutEnd}", timeoutEnd), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return true; + } + + return false; +} + +type TextContext = Context & { message: Message.TextMessage } + +type User = typeof schema.usersTable.$inferSelect + +interface OllamaResponse { + response: string; +} + +async function usingSystemPrompt(ctx: TextContext, db: NodePgDatabase, botName: string, message: string): Promise { + const user = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); + if (user.length === 0) await ensureUserInDb(ctx, db); + const userData = user[0]; + const lang = userData?.languageCode || "en"; + const Strings = getStrings(lang); + const utcDate = new Date().toISOString(); + const prompt = Strings.ai.systemPrompt + .replace("{botName}", botName) + .replace("{date}", utcDate) + .replace("{message}", message); + return prompt; +} + +export function sanitizeForJson(text: string): string { + return text + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') +} + +function sanitizeMarkdownForTelegram(text: string): string { + let sanitizedText = text; + + const replacements: string[] = []; + const addReplacement = (match: string): string => { + replacements.push(match); + return `___PLACEHOLDER_${replacements.length - 1}___`; + }; + + sanitizedText = sanitizedText.replace(/```([\s\S]*?)```/g, addReplacement); + sanitizedText = sanitizedText.replace(/`([^`]+)`/g, addReplacement); + sanitizedText = sanitizedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, addReplacement); + + const parts = sanitizedText.split(/(___PLACEHOLDER_\d+___)/g); + const processedParts = parts.map(part => { + if (part.match(/___PLACEHOLDER_\d+___/)) { + return part; + } else { + let processedPart = part; + processedPart = processedPart.replace(/^(#{1,6})\s+(.+)/gm, '*$2*'); + processedPart = processedPart.replace(/^(\s*)[-*]\s+/gm, '$1- '); + processedPart = processedPart.replace(/\*\*(.*?)\*\*/g, '*$1*'); + processedPart = processedPart.replace(/__(.*?)__/g, '*$1*'); + processedPart = processedPart.replace(/(^|\s)\*(?!\*)([^*]+?)\*(?!\*)/g, '$1_$2_'); + processedPart = processedPart.replace(/(^|\s)_(?!_)([^_]+?)_(?!_)/g, '$1_$2_'); + processedPart = processedPart.replace(/~~(.*?)~~/g, '~$1~'); + processedPart = processedPart.replace(/^\s*┃/gm, '>'); + processedPart = processedPart.replace(/^>\s?/gm, '> '); + + return processedPart; + } + }); + + sanitizedText = processedParts.join(''); + + sanitizedText = sanitizedText.replace(/___PLACEHOLDER_(\d+)___/g, (_, idx) => replacements[Number(idx)]); + + const codeBlockCount = (sanitizedText.match(/```/g) || []).length; + if (codeBlockCount % 2 !== 0) { + sanitizedText += '\n```'; + } + + return sanitizedText; +} + +function processThinkingTags(text: string): string { + let processedText = text; + + const firstThinkIndex = processedText.indexOf(''); + if (firstThinkIndex === -1) { + return processedText.replace(/<\/think>/g, '___THINK_END___'); + } + + processedText = processedText.substring(0, firstThinkIndex) + '___THINK_START___' + processedText.substring(firstThinkIndex + ''.length); + const lastThinkEndIndex = processedText.lastIndexOf(''); + if (lastThinkEndIndex !== -1) { + processedText = processedText.substring(0, lastThinkEndIndex) + '___THEND___' + processedText.substring(lastThinkEndIndex + ''.length); + } + processedText = processedText.replace(//g, ''); + processedText = processedText.replace(/<\/think>/g, ''); + processedText = processedText.replace('___THEND___', '___THINK_END___'); + + return processedText; +} + +export async function preChecks() { + const envs = [ + "ollamaApi", + "flashModel", + "thinkingModel", + ]; + + for (const env of envs) { + if (!process.env[env]) { + console.error(`[✨ AI | !] ❌ ${env} not set!`); + return false; + } + } + + const ollamaApi = process.env.ollamaApi!; + let ollamaOk = false; + for (let i = 0; i < 10; i++) { + try { + const res = await axios.get(ollamaApi, { timeout: 2000 }); + if (res.status === 200) { + ollamaOk = true; + break; + } + } catch (err) { + if (i < 9) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + } + + if (!ollamaOk) { + console.error(`[✨ AI | !] ❌ Ollama API is not responding at ${ollamaApi}`); + return false; + } + + console.log(`[✨ AI] Pre-checks passed.`); + const modelCount = models.reduce((acc, model) => acc + model.models.length, 0); + console.log(`[✨ AI] Found ${modelCount} models.`); + return true; +} + +function isAxiosError(error: unknown): error is { response?: { data?: { error?: string }, status?: number, statusText?: string }, request?: unknown, message?: string } { + return typeof error === 'object' && error !== null && ( + 'response' in error || 'request' in error || 'message' in error + ); +} + +function extractAxiosErrorMessage(error: unknown): string { + if (isAxiosError(error)) { + const err = error as { response?: { data?: { error?: string }, status?: number, statusText?: string }, request?: unknown, message?: string }; + if (err.response && typeof err.response === 'object') { + const resp = err.response; + if (resp.data && typeof resp.data === 'object' && 'error' in resp.data) { + return String(resp.data.error); + } + if ('status' in resp && 'statusText' in resp) { + return `HTTP ${resp.status}: ${resp.statusText}`; + } + return JSON.stringify(resp.data ?? resp); + } + if (err.request) { + return 'No response received from server.'; + } + if (typeof err.message === 'string') { + return err.message; + } + } + return 'An unexpected error occurred.'; +} + +function containsUrls(text: string): boolean { + return text.includes('http://') || text.includes('https://') || text.includes('.com') || text.includes('.net') || text.includes('.org') || text.includes('.io') || text.includes('.ai') || text.includes('.dev') +} + +async function getResponse(prompt: string, ctx: TextContext, replyGenerating: Message, model: string, aiTemperature: number, originalMessage: string, db: NodePgDatabase, userId: string, Strings: ReturnType, showThinking: boolean, abortController?: AbortController): Promise<{ success: boolean; response?: string; error?: string, messageType?: 'generation' | 'system', executionTimeoutReached?: boolean }> { + if (!ctx.chat) { + return { + success: false, + error: Strings.unexpectedErr.replace("{error}", Strings.ai.noChatFound), + }; + } + const cleanedModelName = model.includes('/') ? model.split('/').pop()! : model; + let status = Strings.ai.statusWaitingRender; + let modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + + const promptCharCount = originalMessage.length; + await db.update(schema.usersTable) + .set({ aiCharacters: sql`${schema.usersTable.aiCharacters} + ${promptCharCount}` }) + .where(eq(schema.usersTable.telegramId, userId)); + const paramSizeStr = models.find(m => m.name === model)?.models.find(m => m.name === model)?.parameterSize?.replace('B', ''); + const shouldKeepAlive = paramSizeStr ? Number(paramSizeStr) > unloadModelAfterB : false; + const user = await db.query.usersTable.findFirst({ where: (fields, { eq }) => eq(fields.telegramId, userId) }); + const maxExecutionTime = user?.aiMaxExecutionTime || 0; + const timeout = maxExecutionTime > 0 ? maxExecutionTime * 1000 : 300000; // 5m + + let executionTimeout: NodeJS.Timeout | null = null; + let executionTimeoutReached = false; + let fullResponse = ""; + if (timeout < 300000) { // 5m + executionTimeout = setTimeout(() => { + if (abortController && !abortController.signal.aborted) { + executionTimeoutReached = true; + abortController.abort(); + } + }, timeout); + } + + try { + const aiResponse = await axios.post( + `${process.env.ollamaApi}/api/generate`, + { + model, + prompt, + stream: true, + keep_alive: shouldKeepAlive ? '1' : '0', + options: { + temperature: aiTemperature + } + }, + { + responseType: "stream", + timeout: 60000, //1m + signal: abortController?.signal, + } + ); + let lastUpdateCharCount = 0; + let sentHeader = false; + let firstChunk = true; + const stream: NodeJS.ReadableStream = aiResponse.data as any; + + const formatThinkingMessage = (text: string) => { + const withPlaceholders = text + .replace(/___THINK_START___/g, `${Strings.ai.thinking}`) + .replace(/___THINK_END___/g, `${Strings.ai.finishedThinking}`); + return sanitizeMarkdownForTelegram(withPlaceholders); + }; + + let isThinking = false; + let hasStartedThinking = false; + let hasFinishedThinking = false; + + for await (const chunk of stream) { + const lines = chunk.toString().split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + let ln: OllamaResponse; + try { + ln = JSON.parse(line); + } catch (e) { + console.error("[✨ AI | !] Error parsing chunk"); + continue; + } + + if (ln.response) { + if (ln.response.includes('')) { + const thinkMatch = ln.response.match(/([\s\S]*?)<\/think>/); + if (thinkMatch && thinkMatch[1].trim().length > 0) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true); + } else if (!thinkMatch) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, true); + } + if (!hasStartedThinking) { + isThinking = true; + hasStartedThinking = true; + } + } else if (ln.response.includes('')) { + logger.logThinking(ctx.chat.id, replyGenerating.message_id, false); + if (isThinking && !hasFinishedThinking) { + isThinking = false; + hasFinishedThinking = true; + } + } + fullResponse += ln.response; + if (showThinking) { + let displayResponse = processThinkingTags(fullResponse); + + if (firstChunk) { + status = Strings.ai.statusWaitingRender; + modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + formatThinkingMessage(displayResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = displayResponse.length; + sentHeader = true; + firstChunk = false; + continue; + } + const updateEveryChars = Number(process.env.updateEveryChars) || 100; + if (displayResponse.length - lastUpdateCharCount >= updateEveryChars || !sentHeader) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + formatThinkingMessage(displayResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = displayResponse.length; + sentHeader = true; + } + } else { + if (hasStartedThinking && !hasFinishedThinking && isThinking) { + if (firstChunk) { + status = Strings.ai.statusWaitingRender; + modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + Strings.ai.thinking, + { parse_mode: 'Markdown' } + ); + sentHeader = true; + firstChunk = false; + } + } else if (hasFinishedThinking) { + let processedResponse = processThinkingTags(fullResponse); + let displayResponse = processedResponse.replace(/___THINK_START___[\s\S]*?___THINK_END___/g, '').trim(); + displayResponse = displayResponse.replace(/___THINK_START___[\s\S]*/g, '').trim(); + + if (firstChunk) { + status = Strings.ai.statusWaitingRender; + modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + Strings.ai.finishedThinking + "\n\n" + sanitizeMarkdownForTelegram(displayResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = displayResponse.length; + sentHeader = true; + firstChunk = false; + continue; + } + + const updateEveryChars = Number(process.env.updateEveryChars) || 100; + if (displayResponse.length - lastUpdateCharCount >= updateEveryChars) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + Strings.ai.finishedThinking + "\n\n" + sanitizeMarkdownForTelegram(displayResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = displayResponse.length; + } + } else if (!hasStartedThinking) { + if (firstChunk) { + status = Strings.ai.statusWaitingRender; + modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + sanitizeMarkdownForTelegram(fullResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = fullResponse.length; + sentHeader = true; + firstChunk = false; + continue; + } + + const updateEveryChars = Number(process.env.updateEveryChars) || 100; + if (fullResponse.length - lastUpdateCharCount >= updateEveryChars) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + sanitizeMarkdownForTelegram(fullResponse), + { parse_mode: 'Markdown' } + ); + lastUpdateCharCount = fullResponse.length; + } + } + } + } + } + } + + if (executionTimeout) { + clearTimeout(executionTimeout); + } + + status = Strings.ai.statusRendering; + modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + + if (showThinking) { + let displayResponse = processThinkingTags(fullResponse); + + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + formatThinkingMessage(displayResponse), + { parse_mode: 'Markdown' } + ); + } else { + let processedResponse = processThinkingTags(fullResponse); + let displayResponse = processedResponse.replace(/___THINK_START___[\s\S]*?___THINK_END___/g, '').trim(); + displayResponse = displayResponse.replace(/___THINK_START___[\s\S]*/g, '').trim(); + if (hasStartedThinking) { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + Strings.ai.finishedThinking + "\n\n" + sanitizeMarkdownForTelegram(displayResponse), + { parse_mode: 'Markdown' } + ); + } else { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + sanitizeMarkdownForTelegram(displayResponse), + { parse_mode: 'Markdown' } + ); + } + } + + const responseCharCount = fullResponse.length; + await db.update(schema.usersTable) + .set({ + aiCharacters: sql`${schema.usersTable.aiCharacters} + ${responseCharCount}`, + aiRequests: sql`${schema.usersTable.aiRequests} + 1` + }) + .where(eq(schema.usersTable.telegramId, userId)); + + const patchedResponse = processThinkingTags(fullResponse); + + return { + success: true, + response: patchedResponse, + messageType: 'generation', + executionTimeoutReached + }; + } catch (error: unknown) { + if (executionTimeout) { + clearTimeout(executionTimeout); + } + + if (error instanceof Error && (error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))) { + if (executionTimeoutReached) { + console.log("[✨ AI] Request was aborted due to execution timeout"); + const patchedResponse = processThinkingTags(fullResponse); + return { + success: true, + response: patchedResponse, + messageType: 'generation', + executionTimeoutReached: true + }; + } else { + console.log("[✨ AI] Request was aborted by user"); + return { + success: false, + error: 'Request was aborted' + }; + } + } + + const errorMsg = extractAxiosErrorMessage(error); + console.error("[✨ AI | !] Error:", errorMsg); + if (isAxiosError(error) && error.response && typeof error.response === 'object') { + const resp = error.response as { data?: { error?: string }, status?: number }; + const errData = resp.data && typeof resp.data === 'object' && 'error' in resp.data ? (resp.data as { error?: string }).error : undefined; + const errStatus = 'status' in resp ? resp.status : undefined; + if ((typeof errData === 'string' && errData.includes(`model '${model}' not found`)) || errStatus === 404) { + await ctx.telegram.editMessageText( + ctx.chat!.id, + replyGenerating.message_id, + undefined, + Strings.ai.pulling.replace("{model}", `${cleanedModelName}`), + { parse_mode: 'Markdown' } + ); + console.log(`[✨ AI] Pulling ${model} from ollama...`); + try { + await axios.post( + `${process.env.ollamaApi}/api/pull`, + { + model, + stream: false, + timeout: Number(process.env.ollamaApiTimeout) || 10000, + } + ); + } catch (e: unknown) { + const pullMsg = extractAxiosErrorMessage(e); + console.error("[✨ AI | !] Pull error:", pullMsg); + return { + success: false, + error: `❌ Something went wrong while pulling \`${model}\`: ${pullMsg}`, + messageType: 'system' + }; + } + console.log(`[✨ AI] ${model} pulled successfully`); + return { + success: true, + response: Strings.ai.pulled.replace("{model}", `\`${cleanedModelName}\``), + messageType: 'system' + }; + } + } + return { + success: false, + error: errorMsg, + }; + } +} + +async function handleAiReply(ctx: TextContext, model: string, prompt: string, replyGenerating: Message, aiTemperature: number, originalMessage: string, db: NodePgDatabase, userId: string, Strings: ReturnType, showThinking: boolean, abortController?: AbortController) { + const aiResponse = await getResponse(prompt, ctx, replyGenerating, model, aiTemperature, originalMessage, db, userId, Strings, showThinking, abortController); + if (!aiResponse) return; + if (!ctx.chat) return; + if (!aiResponse.success && aiResponse.error === 'Request was aborted') { + return; + } + if (aiResponse.success && aiResponse.response) { + if (aiResponse.messageType === 'system') { + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + aiResponse.response, + { parse_mode: 'Markdown' } + ); + return; + } + + const cleanedModelName = model.includes('/') ? model.split('/').pop()! : model; + const status = Strings.ai.statusComplete; + const modelHeader = Strings.ai.modelHeader + .replace("{model}", `${cleanedModelName}`) + .replace("{temperature}", String(aiTemperature)) + .replace("{status}", status) + "\n\n"; + const urlWarning = containsUrls(originalMessage) ? Strings.ai.urlWarning : ''; + let finalResponse = aiResponse.response; + const hasThinkingContent = finalResponse.includes('___THINK_START___'); + + if (showThinking) { + finalResponse = finalResponse.replace(/___THINK_START___/g, `${Strings.ai.thinking}`) + .replace(/___THINK_END___/g, `${Strings.ai.finishedThinking}`); + } else { + finalResponse = finalResponse.replace(/___THINK_START___[\s\S]*?___THINK_END___/g, '').trim(); + finalResponse = finalResponse.replace(/___THINK_START___[\s\S]*/g, '').trim(); + } + + const thinkingPrefix = (!showThinking && hasThinkingContent) ? `${Strings.ai.finishedThinking}\n\n` : ''; + const timeoutSuffix = aiResponse.executionTimeoutReached ? Strings.ai.executionTimeoutReached : ''; + + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + modelHeader + thinkingPrefix + sanitizeMarkdownForTelegram(finalResponse) + urlWarning + timeoutSuffix, + { parse_mode: 'Markdown' } + ); + return; + } + const error = Strings.unexpectedErr.replace("{error}", aiResponse.error); + await rateLimiter.editMessageWithRetry( + ctx, + ctx.chat.id, + replyGenerating.message_id, + error, + { parse_mode: 'Markdown' } + ); +} + +async function getUserWithStringsAndModel(ctx: Context, db: NodePgDatabase): Promise<{ user: User; Strings: ReturnType; languageCode: string; customAiModel: string; aiTemperature: number, showThinking: boolean }> { + const userArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); + let user = userArr[0]; + if (!user) { + await ensureUserInDb(ctx, db); + const newUserArr = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(ctx.from!.id)), limit: 1 }); + user = newUserArr[0]; + const Strings = getStrings(user.languageCode); + return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature, showThinking: user.showThinking }; + } + const Strings = getStrings(user.languageCode); + return { user, Strings, languageCode: user.languageCode, customAiModel: user.customAiModel, aiTemperature: user.aiTemperature, showThinking: user.showThinking }; +} + +export function getModelLabelByName(name: string): string { + for (const series of models) { + const found = series.models.find(m => m.name === name); + if (found) return found.label; + } + return name; +} + +export default (bot: Telegraf, db: NodePgDatabase) => { + const botName = bot.botInfo?.first_name && bot.botInfo?.last_name ? `${bot.botInfo.first_name} ${bot.botInfo.last_name}` : "Kowalski" + + interface AiRequest { + task: () => Promise; + ctx: TextContext; + wasQueued: boolean; + userId: number; + model: string; + abortController?: AbortController; + } + + const requestQueue: AiRequest[] = []; + let isProcessing = false; + let lastProcessedUserId: number | null = null; + let currentRequest: AiRequest | null = null; + + async function processQueue() { + if (isProcessing || requestQueue.length === 0) { + return; + } + + isProcessing = true; + + let nextRequestIndex = 0; + if (lastProcessedUserId !== null && requestQueue.length > 1) { + const differentUserIndex = requestQueue.findIndex(req => req.userId !== lastProcessedUserId); + if (differentUserIndex !== -1) { + nextRequestIndex = differentUserIndex; + } + } + + const selectedRequest = requestQueue.splice(nextRequestIndex, 1)[0]; + const { task, ctx, wasQueued, userId } = selectedRequest; + currentRequest = selectedRequest; + + lastProcessedUserId = userId; + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + + try { + if (wasQueued) { + await ctx.reply(Strings.ai.startingProcessing, { + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }), + parse_mode: 'Markdown' + }); + } + await task(); + } catch (error) { + console.error("[✨ AI | !] Error processing task:", error); + if (error.name === 'AbortError' || (error instanceof Error && error.message.toLowerCase().includes('aborted'))) { + console.log("[✨ AI] Request was cancelled by user"); + } else { + const errorMessage = error instanceof Error ? error.message : String(error); + await ctx.reply(Strings.unexpectedErr.replace("{error}", errorMessage), { + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }), + parse_mode: 'Markdown' + }); + } + } finally { + currentRequest = null; + isProcessing = false; + processQueue(); + } + } + + async function aiCommandHandler(ctx: TextContext, command: 'ask' | 'think' | 'ai') { + const commandId = command === 'ask' || command === 'think' ? 'ai-ask-think' : 'ai-custom'; + if (await isCommandDisabled(ctx, db, commandId)) { + return; + } + + const reply_to_message_id = replyToMessageId(ctx); + const { user, Strings, customAiModel, aiTemperature, showThinking } = await getUserWithStringsAndModel(ctx, db); + const message = ctx.message.text; + const author = ("@" + ctx.from?.username) || ctx.from?.first_name || "Unknown"; + + if (await checkUserTimeout(ctx, db, user.telegramId, Strings)) { + return; + } + + const model = command === 'ai' + ? (customAiModel || flash_model) + : (command === 'ask' ? flash_model : thinking_model); + + const fixedMsg = message.replace(new RegExp(`^/${command}(@\\w+)?\\s*`), "").trim(); + logger.logCmdStart(author, command, model); + + if (!process.env.ollamaApi) { + await ctx.reply(Strings.ai.disabled, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) }); + return; + } + + if (!user.aiEnabled) { + await ctx.reply(Strings.ai.disabledForUser, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) }); + return; + } + + if (fixedMsg.length < 1) { + await ctx.reply(Strings.ai.askNoMessage, { parse_mode: 'Markdown', ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) }); + return; + } + + const userId = ctx.from!.id; + const userQueueSize = requestQueue.filter(req => req.userId === userId).length; + + if (userQueueSize >= maxUserQueueSize) { + await ctx.reply(Strings.ai.queueFull, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const abortController = new AbortController(); + const task = async () => { + const modelLabel = getModelLabelByName(model); + const replyGenerating = await ctx.reply(Strings.ai.askGenerating.replace("{model}", `\`${modelLabel}\``), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + const prompt = sanitizeForJson(await usingSystemPrompt(ctx, db, botName, fixedMsg)); + await handleAiReply(ctx, model, prompt, replyGenerating, aiTemperature, fixedMsg, db, user.telegramId, Strings, showThinking, abortController); + }; + + if (isProcessing) { + requestQueue.push({ task, ctx, wasQueued: true, userId: ctx.from!.id, model, abortController }); + const position = requestQueue.length; + await ctx.reply(Strings.ai.inQueue.replace("{position}", String(position)), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } else { + requestQueue.push({ task, ctx, wasQueued: false, userId: ctx.from!.id, model, abortController }); + processQueue(); + } + } + + bot.command(["ask", "think"], spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + const command = ctx.message.text.startsWith('/ask') ? 'ask' : 'think'; + await aiCommandHandler(ctx as TextContext, command); + }); + + bot.command(["ai"], spamwatchMiddleware, async (ctx) => { + if (!ctx.message || !('text' in ctx.message)) return; + await aiCommandHandler(ctx as TextContext, 'ai'); + }); + + bot.command(["aistop"], spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'ai-stop')) { + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + const userId = ctx.from!.id; + + if (currentRequest && currentRequest.userId === userId) { + currentRequest.abortController?.abort(); + + try { + await axios.post(`${process.env.ollamaApi}/api/generate`, { + model: currentRequest.model, + keep_alive: 0, + }, { timeout: 5000 }); + } catch (error) { + console.log("[✨ AI] Could not unload model after cancellation:", error.message); + } + + await ctx.reply(Strings.ai.requestStopped, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const queuedRequestIndex = requestQueue.findIndex(req => req.userId === userId); + if (queuedRequestIndex !== -1) { + const removedRequest = requestQueue.splice(queuedRequestIndex, 1)[0]; + removedRequest.abortController?.abort(); + await ctx.reply(Strings.ai.requestRemovedFromQueue, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + await ctx.reply(Strings.ai.noActiveRequest, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + }); + + bot.command(["aistats"], spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'ai-stats')) { + return; + } + + const { user, Strings } = await getUserWithStringsAndModel(ctx, db); + if (!user) { + await ctx.reply(Strings.userNotFound || "User not found."); + return; + } + const bookCount = Math.max(1, Math.round(user.aiCharacters / 500000)); + const bookWord = bookCount === 1 ? 'book' : 'books'; + const msg = `${Strings.aiStats.header}\n\n${Strings.aiStats.requests.replace('{aiRequests}', user.aiRequests)}\n${Strings.aiStats.characters.replace('{aiCharacters}', user.aiCharacters).replace('{bookCount}', bookCount).replace('books', bookWord)}`; + await ctx.reply(msg, { parse_mode: 'Markdown' }); + }); + + bot.command("queue", spamwatchMiddleware, async (ctx) => { + if (!isAdmin(ctx)) { + const { Strings } = await getUserWithStringsAndModel(ctx, db); + await ctx.reply(Strings.noPermission); + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + + if (requestQueue.length === 0) { + await ctx.reply(Strings.ai.queueEmpty, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + let queueItems = ""; + for (let i = 0; i < requestQueue.length; i++) { + const item = requestQueue[i]; + const username = item.ctx.from?.username || item.ctx.from?.first_name || "Unknown"; + const status = i === 0 && isProcessing ? "Processing" : "Queued"; + const modelLabel = getModelLabelByName(item.model); + queueItems += Strings.ai.queueItem + .replace("{username}", username) + .replace("{userId}", String(item.userId)) + .replace("{model}", modelLabel) + .replace("{status}", status); + } + + const queueMsg = Strings.ai.queueList + .replace("{queueItems}", queueItems) + .replace("{totalItems}", String(requestQueue.length)); + + await ctx.reply(queueMsg, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + }); + + bot.command("qdel", spamwatchMiddleware, async (ctx) => { + if (!isAdmin(ctx)) { + const { Strings } = await getUserWithStringsAndModel(ctx, db); + await ctx.reply(Strings.noPermission); + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + const args = ctx.message.text.split(' '); + + if (args.length < 2) { + await ctx.reply(Strings.ai.invalidUserId, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const targetUserId = parseInt(args[1]); + if (isNaN(targetUserId)) { + await ctx.reply(Strings.ai.invalidUserId, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + let stoppedCurrentRequest = false; + const initialLength = requestQueue.length; + const filteredQueue = requestQueue.filter(item => item.userId !== targetUserId); + const removedCount = initialLength - filteredQueue.length; + + requestQueue.length = 0; + requestQueue.push(...filteredQueue); + + if (currentRequest && currentRequest.userId === targetUserId) { + currentRequest.abortController?.abort(); + + try { + await axios.post(`${process.env.ollamaApi}/api/generate`, { + model: currentRequest.model, + keep_alive: 0, + }, { timeout: 5000 }); + } catch (error) { + console.log("[✨ AI] Could not unload model after cancellation:", error.message); + } + + stoppedCurrentRequest = true; + } + + if (removedCount === 0 && !stoppedCurrentRequest) { + await ctx.reply(Strings.ai.noQueueItems.replace("{userId}", String(targetUserId)), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + let responseMessage = ""; + if (stoppedCurrentRequest && removedCount > 0) { + responseMessage = Strings.ai.stoppedCurrentAndCleared.replace("{count}", String(removedCount)).replace("{userId}", String(targetUserId)); + } else if (stoppedCurrentRequest) { + responseMessage = Strings.ai.stoppedCurrentRequestOnly.replace("{userId}", String(targetUserId)); + } else { + responseMessage = Strings.ai.queueCleared.replace("{count}", String(removedCount)).replace("{userId}", String(targetUserId)); + } + + await ctx.reply(responseMessage, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + }); + + bot.command("qlimit", spamwatchMiddleware, async (ctx) => { + if (!isAdmin(ctx)) { + const { Strings } = await getUserWithStringsAndModel(ctx, db); + await ctx.reply(Strings.noPermission); + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + const args = ctx.message.text.split(' '); + + if (args.length < 3) { + await ctx.reply("Usage: /qlimit \nExample: /qlimit 123456789 1h", { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const targetUserId = args[1]; + const durationStr = args[2]; + + if (!/^\d+$/.test(targetUserId)) { + await ctx.reply(Strings.ai.invalidUserId, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const durationSeconds = parseDuration(durationStr); + if (durationSeconds === -1) { + await ctx.reply(Strings.ai.invalidDuration, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + try { + const user = await db.query.usersTable.findFirst({ where: (fields, { eq }) => eq(fields.telegramId, targetUserId) }); + if (!user) { + await ctx.reply(Strings.ai.userNotFound.replace("{userId}", targetUserId), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const timeoutEnd = new Date(Date.now() + (durationSeconds * 1000)); + await db.update(schema.usersTable) + .set({ aiTimeoutUntil: timeoutEnd }) + .where(eq(schema.usersTable.telegramId, targetUserId)); + + const filteredQueue = requestQueue.filter(item => item.userId !== parseInt(targetUserId)); + requestQueue.length = 0; + requestQueue.push(...filteredQueue); + + await ctx.reply(Strings.ai.userTimedOut.replace("{userId}", targetUserId).replace("{timeoutEnd}", timeoutEnd.toISOString()), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } catch (error) { + await ctx.reply(Strings.ai.userTimeoutError.replace("{userId}", targetUserId).replace("{error}", error.message), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } + }); + + bot.command("setexec", spamwatchMiddleware, async (ctx) => { + if (!isAdmin(ctx)) { + const { Strings } = await getUserWithStringsAndModel(ctx, db); + await ctx.reply(Strings.noPermission); + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + const args = ctx.message.text.split(' '); + + if (args.length < 3) { + await ctx.reply("Usage: /setexec \nExample: /setexec 123456789 5m\nUse 'unlimited' to remove limit.", { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const targetUserId = args[1]; + const durationStr = args[2]; + + if (!/^\d+$/.test(targetUserId)) { + await ctx.reply(Strings.ai.invalidUserId, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + let durationSeconds = 0; + if (durationStr.toLowerCase() !== 'unlimited') { + durationSeconds = parseDuration(durationStr); + if (durationSeconds === -1) { + await ctx.reply(Strings.ai.invalidDuration, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + } + + try { + const user = await db.query.usersTable.findFirst({ where: (fields, { eq }) => eq(fields.telegramId, targetUserId) }); + if (!user) { + await ctx.reply(Strings.ai.userNotFound.replace("{userId}", targetUserId), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + await db.update(schema.usersTable) + .set({ aiMaxExecutionTime: durationSeconds }) + .where(eq(schema.usersTable.telegramId, targetUserId)); + + if (durationSeconds === 0) { + await ctx.reply(Strings.ai.userExecTimeRemoved.replace("{userId}", targetUserId), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } else { + await ctx.reply(Strings.ai.userExecTimeSet.replace("{duration}", formatDuration(durationSeconds)).replace("{userId}", targetUserId), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } + } catch (error) { + await ctx.reply(Strings.ai.userExecTimeError.replace("{userId}", targetUserId).replace("{error}", error.message), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } + }); + + bot.command("rlimit", spamwatchMiddleware, async (ctx) => { + if (!isAdmin(ctx)) { + const { Strings } = await getUserWithStringsAndModel(ctx, db); + await ctx.reply(Strings.noPermission); + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + const args = ctx.message.text.split(' '); + + if (args.length < 2) { + await ctx.reply("Usage: /rlimit \nExample: /rlimit 123456789", { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + const targetUserId = args[1]; + + if (!/^\d+$/.test(targetUserId)) { + await ctx.reply(Strings.ai.invalidUserId, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + try { + const user = await db.query.usersTable.findFirst({ where: (fields, { eq }) => eq(fields.telegramId, targetUserId) }); + if (!user) { + await ctx.reply(Strings.ai.userNotFound.replace("{userId}", targetUserId), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + await db.update(schema.usersTable) + .set({ + aiTimeoutUntil: null, + aiMaxExecutionTime: 0 + }) + .where(eq(schema.usersTable.telegramId, targetUserId)); + + await ctx.reply(Strings.ai.userLimitsRemoved.replace("{userId}", targetUserId), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } catch (error) { + await ctx.reply(Strings.ai.userLimitRemoveError.replace("{userId}", targetUserId).replace("{error}", error.message), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } + }); + + bot.command("limits", spamwatchMiddleware, async (ctx) => { + if (!isAdmin(ctx)) { + const { Strings } = await getUserWithStringsAndModel(ctx, db); + await ctx.reply(Strings.noPermission); + return; + } + + const { Strings } = await getUserWithStringsAndModel(ctx, db); + const reply_to_message_id = replyToMessageId(ctx); + + try { + const usersWithTimeouts = await db.query.usersTable.findMany({ + where: and( + isNotNull(schema.usersTable.aiTimeoutUntil), + gt(schema.usersTable.aiTimeoutUntil, new Date()) + ), + columns: { + telegramId: true, + username: true, + firstName: true, + aiTimeoutUntil: true + } + }); + + const usersWithExecLimits = await db.query.usersTable.findMany({ + where: gt(schema.usersTable.aiMaxExecutionTime, 0), + columns: { + telegramId: true, + username: true, + firstName: true, + aiMaxExecutionTime: true + } + }); + + if (usersWithTimeouts.length === 0 && usersWithExecLimits.length === 0) { + await ctx.reply(Strings.ai.noLimitsSet, { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + return; + } + + let limitsText = Strings.ai.limitsHeader + "\n\n"; + + if (usersWithTimeouts.length > 0) { + limitsText += Strings.ai.timeoutLimitsHeader + "\n"; + for (const user of usersWithTimeouts) { + const displayName = user.username || user.firstName || "Unknown"; + const timeoutEnd = user.aiTimeoutUntil!.toISOString(); + limitsText += Strings.ai.timeoutLimitItem + .replace("{displayName}", displayName) + .replace("{userId}", user.telegramId) + .replace("{timeoutEnd}", timeoutEnd) + "\n"; + } + limitsText += "\n"; + } + + if (usersWithExecLimits.length > 0) { + limitsText += Strings.ai.execLimitsHeader + "\n"; + for (const user of usersWithExecLimits) { + const displayName = user.username || user.firstName || "Unknown"; + const execTime = formatDuration(user.aiMaxExecutionTime!); + limitsText += Strings.ai.execLimitItem + .replace("{displayName}", displayName) + .replace("{userId}", user.telegramId) + .replace("{execTime}", execTime) + "\n"; + } + } + + await ctx.reply(limitsText.trim(), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } catch (error) { + await ctx.reply(Strings.ai.limitsListError.replace("{error}", error.message), { + parse_mode: 'Markdown', + ...(reply_to_message_id && { reply_parameters: { message_id: reply_to_message_id } }) + }); + } + }); +} diff --git a/telegram/commands/animal.ts b/telegram/commands/animal.ts new file mode 100755 index 0000000..a409e75 --- /dev/null +++ b/telegram/commands/animal.ts @@ -0,0 +1,159 @@ +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'; +import { isCommandDisabled } from '../utils/check-command-disabled'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export const duckHandler = 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 ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const Strings = getStrings(languageCode(ctx)); + const message = Strings.duckApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export const foxHandler = 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 ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.foxApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export const dogHandler = 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 ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.dogApiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export const catHandler = async (ctx: Context & { message: { text: string } }) => { + const Strings = getStrings(languageCode(ctx)); + const apiUrl = `${Resources.catApi}?json=true`; + const reply_to_message_id = replyToMessageId(ctx); + try { + const response = await axios.get(apiUrl); + const data = response.data; + const imageUrl = `${data.url}`; + await ctx.replyWithPhoto(imageUrl, { + caption: `🐱`, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + } catch (error) { + const message = Strings.catImgErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + return; + } +}; + +export const soggyHandler = 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 ? { reply_parameters: { message_id: 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 ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + break; + + default: + ctx.replyWithPhoto( + Resources.soggyCat, { + caption: Resources.soggyCat, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + break; + }; +}; + +export default (bot: Telegraf, db: any) => { + bot.command("duck", spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'animals-basic')) return; + await duckHandler(ctx); + }); + + bot.command("fox", spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'animals-basic')) return; + await foxHandler(ctx); + }); + + bot.command("dog", spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'animals-basic')) return; + await dogHandler(ctx); + }); + + bot.command("cat", spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'animals-basic')) return; + await catHandler(ctx); + }); + + bot.command(['soggy', 'soggycat'], spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'soggy-cat')) return; + await soggyHandler(ctx); + }); +} diff --git a/telegram/commands/codename.ts b/telegram/commands/codename.ts new file mode 100755 index 0000000..cb2734d --- /dev/null +++ b/telegram/commands/codename.ts @@ -0,0 +1,88 @@ +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 { replyToMessageId } from '../utils/reply-to-message-id'; +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); + +interface Device { + brand: string; + codename: string; + model: string; + name: string; +} + +export async function getDeviceByCodename(codename: string): Promise { + try { + const response = await axios.get(Resources.codenameApi); + const jsonRes = response.data; + const deviceDetails = jsonRes[codename]; + if (!deviceDetails) return null; + return deviceDetails.find((item: Device) => item.brand) || deviceDetails[0]; + } catch (error) { + return null; + } +} + +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(['codename', 'whatis'], spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'codename-lookup')) return; + + const userInput = ctx.message.text.split(" ").slice(1).join(" "); + const { Strings } = await getUserAndStrings(ctx, db); + const { noCodename } = Strings.codenameCheck; + const reply_to_message_id = replyToMessageId(ctx); + + if (verifyInput(ctx, userInput, noCodename)) { + return; + } + + const device = await getDeviceByCodename(userInput); + + if (!device) { + return ctx.reply(Strings.codenameCheck.notFound, { + parse_mode: "Markdown", + ...({ reply_to_message_id }) + }); + } + + const message = Strings.codenameCheck.resultMsg + .replace('{brand}', device.brand) + .replace('{codename}', userInput) + .replace('{model}', device.model) + .replace('{name}', device.name); + + return ctx.reply(message, { + parse_mode: 'Markdown', + ...({ reply_to_message_id }) + }); + }) +} \ No newline at end of file diff --git a/telegram/commands/crew.ts b/telegram/commands/crew.ts new file mode 100755 index 0000000..f32950a --- /dev/null +++ b/telegram/commands/crew.ts @@ -0,0 +1,271 @@ +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import os from 'os'; +import { exec } from 'child_process'; +import { error } from 'console'; +import { Context, Telegraf } from 'telegraf'; +import * as schema from '../../database/schema'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +function getGitCommitHash() { + return new Promise((resolve, reject) => { + exec('git rev-parse --short HEAD', (error, stdout, stderr) => { + if (error) { + reject(`Error: ${stderr}`); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +function updateBot() { + return new Promise((resolve, reject) => { + exec('git pull && echo "A" >> restart.txt', (error, stdout, stderr) => { + if (error) { + reject(`Error: ${stderr}`); + } else { + resolve(stdout.trim()); + } + }); + }); +} + +function formatUptime(uptime: number) { + const hours = Math.floor(uptime / 3600); + const minutes = Math.floor((uptime % 3600) / 60); + const seconds = Math.floor(uptime % 60); + return `${hours}h ${minutes}m ${seconds}s`; +} + +function getSystemInfo() { + const { platform, release, arch, cpus, totalmem, freemem, loadavg, uptime } = os; + const [cpu] = cpus(); + return `*Server Stats*\n\n` + + `*OS:* \`${platform()} ${release()}\`\n` + + `*Arch:* \`${arch()}\`\n` + + `*Node.js Version:* \`${process.version}\`\n` + + `*CPU:* \`${cpu.model}\`\n` + + `*CPU Cores:* \`${cpus().length} cores\`\n` + + `*RAM:* \`${(freemem() / (1024 ** 3)).toFixed(2)} GB / ${(totalmem() / (1024 ** 3)).toFixed(2)} GB\`\n` + + `*Load Average:* \`${loadavg().map(avg => avg.toFixed(2)).join(', ')}\`\n` + + `*Uptime:* \`${formatUptime(uptime())}\`\n\n`; +} + +async function handleAdminCommand(ctx: Context & { message: { text: string } }, action: () => Promise, successMessage: string, errorMessage: string) { + const { Strings } = await getUserAndStrings(ctx); + const userId = ctx.from?.id; + const adminArray = process.env.botAdmins ? process.env.botAdmins.split(',').map(id => parseInt(id.trim())) : []; + if (userId && adminArray.includes(userId)) { + try { + await action(); + if (successMessage) { + ctx.reply(successMessage, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + } catch (error) { + ctx.reply(errorMessage.replace(/{error}/g, error.message), { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + } else { + ctx.reply(Strings.noPermission, { + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } +} + +export default (bot: Telegraf, db) => { + bot.command('getbotstats', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + handleAdminCommand(ctx, async () => { + const stats = getSystemInfo(); + await ctx.reply(stats, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }, '', Strings.errorRetrievingStats); + }); + + bot.command('getbotcommit', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + handleAdminCommand(ctx, async () => { + try { + const commitHash = await getGitCommitHash(); + await ctx.reply(Strings.gitCurrentCommit.replace(/{commitHash}/g, commitHash), { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } catch (error) { + ctx.reply(Strings.gitErrRetrievingCommit.replace(/{error}/g, error), { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + }, '', Strings.gitErrRetrievingCommit); + }); + + bot.command('updatebot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + handleAdminCommand(ctx, async () => { + try { + const result = await updateBot(); + await ctx.reply(Strings.botUpdated.replace(/{result}/g, result), { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } catch (error) { + ctx.reply(Strings.errorUpdatingBot.replace(/{error}/g, error), { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + }, '', Strings.errorUpdatingBot); + }); + + bot.command('setbotname', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + const botName = ctx.message.text.split(' ').slice(1).join(' '); + handleAdminCommand(ctx, async () => { + await ctx.telegram.setMyName(botName); + }, Strings.botNameChanged.replace(/{botName}/g, botName), Strings.botNameErr.replace(/{error}/g, error)); + }); + + bot.command('setbotdesc', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + const botDesc = ctx.message.text.split(' ').slice(1).join(' '); + handleAdminCommand(ctx, async () => { + await ctx.telegram.setMyDescription(botDesc); + }, Strings.botDescChanged.replace(/{botDesc}/g, botDesc), Strings.botDescErr.replace(/{error}/g, error)); + }); + + bot.command('botkickme', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + handleAdminCommand(ctx, async () => { + if (!ctx.chat) { + ctx.reply(Strings.chatNotFound, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + return; + } + ctx.reply(Strings.kickingMyself, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + await ctx.telegram.leaveChat(ctx.chat.id); + }, '', Strings.kickingMyselfErr); + }); + + bot.command('getfile', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const { Strings } = await getUserAndStrings(ctx, db); + const botFile = ctx.message.text.split(' ').slice(1).join(' '); + + if (!botFile) { + ctx.reply(Strings.noFileProvided, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + return; + } + + handleAdminCommand(ctx, async () => { + try { + await ctx.replyWithDocument({ + // @ts-ignore + source: botFile, + caption: botFile + }, { + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } catch (error) { + ctx.reply(Strings.unexpectedErr.replace(/{error}/g, error.message), { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + }, '', Strings.unexpectedErr); + }); + + bot.command('run', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const command = ctx.message.text.split(' ').slice(1).join(' '); + handleAdminCommand(ctx, async () => { + if (!command) { + ctx.reply('Por favor, forneça um comando para executar.'); + return; + } + + exec(command, (error, stdout, stderr) => { + if (error) { + return ctx.reply(`\`${error.message}\``, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + if (stderr) { + return ctx.reply(`\`${stderr}\``, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + ctx.reply(`\`${stdout}\``, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }); + }, '', "Nope!"); + }); + + bot.command('eval', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + const code = ctx.message.text.split(' ').slice(1).join(' '); + if (!code) { + return ctx.reply('Por favor, forneça um código para avaliar.'); + } + + try { + const result = eval(code); + ctx.reply(`Result: ${result}`, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } catch (error) { + ctx.reply(`Error: ${error.message}`, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + }); + + bot.command('crash', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + handleAdminCommand(ctx, async () => { + ctx.reply('Crashed!'); + }, '', "Nope!"); + }); +}; diff --git a/telegram/commands/fun.ts b/telegram/commands/fun.ts new file mode 100755 index 0000000..1d8f6e7 --- /dev/null +++ b/telegram/commands/fun.ts @@ -0,0 +1,140 @@ +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 * 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 }; +} + +function sendRandomReply(ctx: Context & { message: { text: string } }, gifUrl: string, textKey: string, db: any) { + getUserAndStrings(ctx, db).then(({ Strings }) => { + 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', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }).catch(err => { + const gifErr = Strings.gifErr.replace('{err}', err); + ctx.reply(gifErr, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }); + } else { + ctx.reply(caption, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } + }); +} + +async function handleDiceCommand(ctx: Context & { message: { text: string } }, emoji: string, delay: number, db: any) { + const { Strings } = await getUserAndStrings(ctx, db); + + // @ts-ignore + const result = await ctx.sendDice({ emoji, ...(ctx.message?.message_id ? { reply_parameters: { 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', + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }, delay); +} + +function getRandomInt(max: number) { + return Math.floor(Math.random() * (max + 1)); +} + +export default (bot: Telegraf, db) => { + bot.command('random', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'fun-random')) return; + + const { Strings } = await getUserAndStrings(ctx, db); + const randomValue = getRandomInt(10); + const randomVStr = Strings.randomNum.replace('{number}', randomValue); + + ctx.reply( + randomVStr, { + parse_mode: 'Markdown', + ...(ctx.message?.message_id ? { reply_parameters: { 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 } }) => { + if (await isCommandDisabled(ctx, db, 'games-dice')) return; + await handleDiceCommand(ctx, '🎲', 4000, db); + }); + + bot.command('slot', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'games-dice')) return; + await handleDiceCommand(ctx, '🎰', 3000, db); + }); + + bot.command('ball', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'games-dice')) return; + await handleDiceCommand(ctx, '⚽', 3000, db); + }); + + bot.command('dart', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'games-dice')) return; + await handleDiceCommand(ctx, '🎯', 3000, db); + }); + + bot.command('bowling', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'games-dice')) return; + await handleDiceCommand(ctx, '🎳', 3000, db); + }); + + bot.command('idice', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'infinite-dice')) return; + + const { Strings } = await getUserAndStrings(ctx, db); + ctx.replyWithSticker( + Resources.infiniteDice, { + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }); + + bot.command('furry', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'fun-random')) return; + sendRandomReply(ctx, Resources.furryGif, 'furryAmount', db); + }); + + bot.command('gay', spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'fun-random')) return; + sendRandomReply(ctx, Resources.gayFlag, 'gayAmount', db); + }); +}; \ No newline at end of file diff --git a/telegram/commands/gsmarena.ts b/telegram/commands/gsmarena.ts new file mode 100755 index 0000000..b345a00 --- /dev/null +++ b/telegram/commands/gsmarena.ts @@ -0,0 +1,341 @@ +// Ported and improved from Hitalo's PyKorone bot +// Copyright (c) 2024 Hitalo M. (https://github.com/HitaloM) +// Original code license: BSD-3-Clause +// With some help from GPT (I don't really like AI but whatever) +// If this were a kang, I would not be giving credits to him! + +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import axios from 'axios'; +import { parse } from 'node-html-parser'; +import { getDeviceByCodename } from './codename'; +import { getStrings } from '../plugins/checklang'; +import { languageCode } from '../utils/language-code'; +import { isCommandDisabled } from '../utils/check-command-disabled'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +interface PhoneSearchResult { + name: string; + url: string; +} + +interface PhoneDetails { + specs: Record>; + name?: string; + url?: string; + picture?: string; +} + +const HEADERS = { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" +}; + +function getDataFromSpecs(specsData, category, attributes) { + const details = specsData?.specs?.[category] || {}; + + return attributes + .map(attr => details[attr] || null) + .filter(Boolean) + .join("\n"); +} + +function parseSpecs(specsData: PhoneDetails): PhoneDetails { + const categories = { + "status": ["Launch", ["Status"]], + "network": ["Network", ["Technology"]], + "system": ["Platform", ["OS"]], + "models": ["Misc", ["Models"]], + "weight": ["Body", ["Weight"]], + "jack": ["Sound", ["3.5mm jack"]], + "usb": ["Comms", ["USB"]], + "sensors": ["Features", ["Sensors"]], + "battery": ["Battery", ["Type"]], + "charging": ["Battery", ["Charging"]], + "display_type": ["Display", ["Type"]], + "display_size": ["Display", ["Size"]], + "display_resolution": ["Display", ["Resolution"]], + "platform_chipset": ["Platform", ["Chipset"]], + "platform_cpu": ["Platform", ["CPU"]], + "platform_gpu": ["Platform", ["GPU"]], + "memory": ["Memory", ["Internal"]], + "main_camera_single": ["Main Camera", ["Single"]], + "main_camera_dual": ["Main Camera", ["Dual"]], + "main_camera_triple": ["Main Camera", ["Triple"]], + "main_camera_quad": ["Main Camera", ["Quad"]], + "main_camera_features": ["Main Camera", ["Features"]], + "main_camera_video": ["Main Camera", ["Video"]], + "selfie_camera_single": ["Selfie Camera", ["Single"]], + "selfie_camera_dual": ["Selfie Camera", ["Dual"]], + "selfie_camera_triple": ["Selfie Camera", ["Triple"]], + "selfie_camera_quad": ["Selfie Camera", ["Quad"]], + "selfie_camera_features": ["Selfie Camera", ["Features"]], + "selfie_camera_video": ["Selfie Camera", ["Video"]] + }; + + const parsedData = Object.keys(categories).reduce((acc, key) => { + const [cat, attrs] = categories[key]; + acc[key] = getDataFromSpecs(specsData, cat, attrs) || ""; + return acc; + }, { specs: {} } as PhoneDetails); + + parsedData["name"] = specsData.name || ""; + parsedData["url"] = specsData.url || ""; + + return parsedData; +} + +function formatPhone(phone: PhoneDetails) { + const formattedPhone = parseSpecs(phone); + const attributesDict = { + "Status": "status", + "Network": "network", + "OS": "system", + "Models": "models", + "Weight": "weight", + "3.5mm jack": "jack", + "USB": "usb", + "Sensors": "sensors", + "Battery": "battery", + "Charging": "charging", + "Display Type": "display_type", + "Display Size": "display_size", + "Display Resolution": "display_resolution", + "Chipset": "platform_chipset", + "CPU": "platform_cpu", + "GPU": "platform_gpu", + "Memory": "memory", + "Rear Camera (Single)": "main_camera_single", + "Rear Camera (Dual)": "main_camera_dual", + "Rear Camera (Triple)": "main_camera_triple", + "Rear Camera (Quad)": "main_camera_quad", + "Rear Camera (Features)": "main_camera_features", + "Rear Camera (Video)": "main_camera_video", + "Front Camera (Single)": "selfie_camera_single", + "Front Camera (Dual)": "selfie_camera_dual", + "Front Camera (Triple)": "selfie_camera_triple", + "Front Camera (Quad)": "selfie_camera_quad", + "Front Camera (Features)": "selfie_camera_features", + "Front Camera (Video)": "selfie_camera_video" + }; + + const attributes = Object.entries(attributesDict) + .filter(([_, key]) => formattedPhone[key]) + .map(([label, key]) => `${label}: ${formattedPhone[key]}`) + .join("\n\n"); + + const deviceUrl = `GSMArena page: ${formattedPhone.url}`; + const deviceImage = phone.picture ? `Device Image: ${phone.picture}` : ''; + + return `\n\nName: ${formattedPhone.name}\n\n${attributes}\n\n${deviceImage}\n\n${deviceUrl}`; +} + +async function fetchHtml(url: string) { + try { + const response = await axios.get(url, { headers: HEADERS }); + return response.data; + } catch (error) { + console.error("Error fetching HTML:", error); + throw error; + } +} + +async function searchPhone(phone: string): Promise { + try { + const searchUrl = `https://m.gsmarena.com/results.php3?sQuickSearch=yes&sName=${encodeURIComponent(phone)}`; + const htmlContent = await fetchHtml(searchUrl); + const root = parse(htmlContent); + const foundPhones = root.querySelectorAll('.general-menu.material-card ul li'); + + return foundPhones.map((phoneTag) => { + const name = phoneTag.querySelector('img')?.getAttribute('title') || ""; + const url = phoneTag.querySelector('a')?.getAttribute('href') || ""; + return { name, url }; + }); + } catch (error) { + console.error("Error searching for phone:", error); + return []; + } +} + +async function checkPhoneDetails(url) { + try { + const htmlContent = await fetchHtml(`https://www.gsmarena.com/${url}`); + const root = parse(htmlContent); + const specsTables = root.querySelectorAll('table[cellspacing="0"]'); + const specsData = extractSpecs(specsTables); + const metaScripts = root.querySelectorAll('script[language="javascript"]'); + const meta = metaScripts.length ? metaScripts[0].text.split("\n") : []; + const name = extractMetaData(meta, "ITEM_NAME"); + const picture = extractMetaData(meta, "ITEM_IMAGE"); + + return { ...specsData, name, picture, url: `https://www.gsmarena.com/${url}` }; + } catch (error) { + console.error("Error fetching phone details:", error); + return { specs: {}, name: "", url: "", picture: "" }; + } +} + +function extractSpecs(specsTables) { + return { + specs: specsTables.reduce((acc, table) => { + const feature = table.querySelector('th')?.text.trim() || ""; + table.querySelectorAll('tr').forEach((tr) => { + const header = tr.querySelector('.ttl')?.text.trim() || "info"; + let detail = tr.querySelector('.nfo')?.text.trim() || ""; + detail = detail.replace(/\s*\n\s*/g, " / ").trim(); + if (!acc[feature]) { + acc[feature] = {}; + } + acc[feature][header] = acc[feature][header] + ? `${acc[feature][header]} / ${detail}` + : detail; + }); + return acc; + }, {}) + }; +} + +function extractMetaData(meta, key) { + const line = meta.find((line) => line.includes(key)); + return line ? line.split('"')[1] : ""; +} + +function getUsername(ctx){ + let userName = String(ctx.from.first_name); + if(userName.includes("<") && userName.includes(">")) { + userName = userName.replaceAll("<", "").replaceAll(">", ""); + } + return userName; +} + +const deviceSelectionCache: Record = {}; +const lastSelectionMessageId: Record = {}; + +export default (bot, db) => { + bot.command(['d', 'device'], spamwatchMiddleware, async (ctx) => { + if (await isCommandDisabled(ctx, db, 'device-specs')) return; + + const userId = ctx.from.id; + const userName = getUsername(ctx); + const Strings = getStrings(languageCode(ctx)); + + const phone = ctx.message.text.split(" ").slice(1).join(" "); + if (!phone) { + return ctx.reply(Strings.gsmarenaProvidePhoneName || "[TODO: Add gsmarenaProvidePhoneName to locales] Please provide the phone name.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + } + + console.log("[GSMArena] Searching for", phone); + const statusMsg = await ctx.reply((Strings.gsmarenaSearchingFor || "[TODO: Add gsmarenaSearchingFor to locales] Searching for {phone}...").replace('{phone}', phone), { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}), parse_mode: 'Markdown' }); + + let results = await searchPhone(phone); + if (results.length === 0) { + const codenameResults = await getDeviceByCodename(phone.split(" ")[0]); + if (!codenameResults) { + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaNoPhonesFound || "[TODO: Add gsmarenaNoPhonesFound to locales] No phones found for {phone}.").replace('{phone}', phone), { parse_mode: 'Markdown' }); + return; + } + + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaSearchingFor || "[TODO: Add gsmarenaSearchingFor to locales] Searching for {phone}...").replace('{phone}', codenameResults.name), { parse_mode: 'Markdown' }); + const nameResults = await searchPhone(codenameResults.name); + if (nameResults.length === 0) { + await ctx.telegram.editMessageText(ctx.chat.id, statusMsg.message_id, undefined, (Strings.gsmarenaNoPhonesFoundBoth || "[TODO: Add gsmarenaNoPhonesFoundBoth to locales] No phones found for {name} and {phone}.").replace('{name}', codenameResults.name).replace('{phone}', phone), { parse_mode: 'Markdown' }); + return; + } + results = nameResults; + } + + if (deviceSelectionCache[userId]?.timeout) { + clearTimeout(deviceSelectionCache[userId].timeout); + } + deviceSelectionCache[userId] = { + results, + timeout: setTimeout(() => { delete deviceSelectionCache[userId]; }, 5 * 60 * 1000) + }; + + if (lastSelectionMessageId[userId]) { + try { + await ctx.telegram.editMessageText( + ctx.chat.id, + lastSelectionMessageId[userId], + undefined, + Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] Please select your device:", + { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }) + } + } + ); + } catch (e) { + const testUser = `${userName}, ${Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] please select your device:"}`; + const options = { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }) + } + }; + const selectionMsg = await ctx.reply(testUser, options); + lastSelectionMessageId[userId] = selectionMsg.message_id; + } + } else { + const testUser = `${userName}, ${Strings.gsmarenaSelectDevice || "[TODO: Add gsmarenaSelectDevice to locales] please select your device:"}`; + const inlineKeyboard = results.map((result, idx) => { + const callbackData = `gsmadetails:${idx}:${ctx.from.id}`; + return [{ text: result.name, callback_data: callbackData }]; + }); + const options = { + parse_mode: 'HTML', + reply_to_message_id: ctx.message.message_id, + disable_web_page_preview: true, + reply_markup: { + inline_keyboard: inlineKeyboard + } + }; + const selectionMsg = await ctx.reply(testUser, options); + lastSelectionMessageId[userId] = selectionMsg.message_id; + } + await ctx.telegram.deleteMessage(ctx.chat.id, statusMsg.message_id).catch(() => {}); + }); + + bot.action(/gsmadetails:(\d+):(\d+)/, async (ctx) => { + const idx = parseInt(ctx.match[1]); + const userId = parseInt(ctx.match[2]); + const userName = getUsername(ctx); + const Strings = getStrings(languageCode(ctx)); + + const callbackQueryUserId = ctx.update.callback_query.from.id; + + if (userId !== callbackQueryUserId) { + return ctx.answerCbQuery(`${userName}, ${Strings.gsmarenaNotAllowed || "[TODO: Add gsmarenaNotAllowed to locales] you are not allowed to interact with this."}`); + } + + ctx.answerCbQuery(); + + const cache = deviceSelectionCache[userId]; + if (!cache || !cache.results[idx]) { + return ctx.reply(Strings.gsmarenaInvalidOrExpired || "[TODO: Add gsmarenaInvalidOrExpired to locales] Whoops, invalid or expired option. Please try again.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + } + const url = cache.results[idx].url; + + const phoneDetails = await checkPhoneDetails(url); + + if (phoneDetails.name) { + const message = formatPhone(phoneDetails); + ctx.editMessageText(`${userName}, ${Strings.gsmarenaDeviceDetails || "[TODO: Add gsmarenaDeviceDetails to locales] these are the details of your device:"}` + message, { parse_mode: 'HTML', disable_web_page_preview: false }); + } else { + ctx.reply(Strings.gsmarenaErrorFetchingDetails || "[TODO: Add gsmarenaErrorFetchingDetails to locales] Error fetching phone details.", { ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) }); + } + }); +}; 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/telegram/commands/http.ts b/telegram/commands/http.ts new file mode 100755 index 0000000..5fd4ef9 --- /dev/null +++ b/telegram/commands/http.ts @@ -0,0 +1,114 @@ +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 * as schema from '../../database/schema'; +import { languageCode } from '../utils/language-code'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { isCommandDisabled } from '../utils/check-command-disabled'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +async function getUserAndStrings(ctx: Context, db?: NodePgDatabase): Promise<{ Strings: any, languageCode: string }> { + let languageCode = 'en'; + if (!ctx.from) { + const Strings = getStrings(languageCode); + return { Strings, languageCode }; + } + const from = ctx.from; + if (db && from.id) { + const dbUser = await db.query.usersTable.findMany({ where: (fields, { eq }) => eq(fields.telegramId, String(from.id)), limit: 1 }); + if (dbUser.length > 0) { + languageCode = dbUser[0].languageCode; + } + } + if (from.language_code && languageCode === 'en') { + languageCode = from.language_code; + console.warn('[WARN !] Falling back to Telegram language_code for user', from.id); + } + const Strings = getStrings(languageCode); + return { Strings, languageCode }; +} + +export default (bot: Telegraf, db) => { + bot.command("http", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'http-status')) return; + + const reply_to_message_id = ctx.message.message_id; + const { Strings } = await getUserAndStrings(ctx, db); + const userInput = ctx.message.text.split(' ')[1]; + const apiUrl = Resources.httpApi; + const { invalidCode } = Strings.httpCodes + + if (verifyInput(ctx, userInput, invalidCode, true)) { + return; + } + + try { + const response = await axios.get(apiUrl); + const data = response.data; + const codesArray = Array.isArray(data) ? data : Object.values(data); + const codeInfo = codesArray.find(item => item.code === parseInt(userInput)); + + if (codeInfo) { + const message = Strings.httpCodes.resultMsg + .replace("{code}", codeInfo.code) + .replace("{message}", codeInfo.message) + .replace("{description}", codeInfo.description); + await ctx.reply(message, { + parse_mode: 'Markdown', + ...(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_parameters: { message_id: reply_to_message_id } } : {}) + }); + }; + } catch (error) { + const message = Strings.httpCodes.fetchErr.replace('{error}', error); + ctx.reply(message, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); + }; + }); + + 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, ''); + const { invalidCode } = Strings.httpCodes + + if (verifyInput(ctx, userInput, invalidCode, true)) { + return; + } + if (userInput.length !== 3) { + ctx.reply(Strings.httpCodes.invalidCode, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }) + return + } + + const apiUrl = `${Resources.httpCatApi}${userInput}`; + + try { + await ctx.replyWithPhoto(apiUrl, { + caption: `🐱 ${apiUrl}`, + parse_mode: 'Markdown', + ...(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_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/telegram/commands/lastfm.ts b/telegram/commands/lastfm.ts new file mode 100755 index 0000000..2ccecbf --- /dev/null +++ b/telegram/commands/lastfm.ts @@ -0,0 +1,229 @@ +import Resources from '../props/resources.json'; +import fs from 'fs'; +import axios from 'axios'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import { isCommandDisabled } from '../utils/check-command-disabled'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +const scrobbler_url = Resources.lastFmApi; +const api_key = process.env.lastKey; + +const dbFile = 'telegram/props/lastfm.json'; +let users = {}; + +function loadUsers() { + if (!fs.existsSync(dbFile)) { + console.log(`WARN: Last.fm user database ${dbFile} not found. Creating a new one.`); + saveUsers(); + return; + } + + try { + const data = fs.readFileSync(dbFile, 'utf-8'); + users = JSON.parse(data); + } catch (err) { + console.log("WARN: Error loading the Last.fm user database:", err); + users = {}; + } +} + +function saveUsers() { + try { + fs.writeFileSync(dbFile, JSON.stringify(users, null, 2), 'utf-8'); + } catch (err) { + console.error("WARN: Error saving Last.fm users:", err); + } +} + +async function getFromMusicBrainz(mbid: string) { + try { + const response = await axios.get(`${Resources.musicBrainzApi}${mbid}`); + const imgObjLarge = response.data.images[0]?.thumbnails?.['1200']; + const imgObjMid = response.data.images[0]?.thumbnails?.large; + const imageUrl = imgObjLarge || imgObjMid || ''; + return imageUrl; + } catch (error) { + return undefined; + } +} + + +function getFromLast(track) { + if (!track || !track.image) return ''; + + const imageExtralarge = track.image.find(img => img.size === 'extralarge'); + const imageMega = track.image.find(img => img.size === 'mega'); + const imageUrl = (imageExtralarge?.['#text']) || (imageMega?.['#text']) || ''; + + return imageUrl; +} + +export default (bot, db) => { + loadUsers(); + + 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]; + + if (!lastUser) { + return ctx.reply(Strings.lastFm.noUser, { + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }; + + users[userId] = lastUser; + saveUsers(); + + const message = Strings.lastFm.userHasBeenSet.replace('{lastUser}', lastUser); + + ctx.reply(message, { + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(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, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }; + + try { + const response = await axios.get(scrobbler_url, { + params: { + method: 'user.getRecentTracks', + user: lastfmUser, + api_key, + format: 'json', + limit: 1 + }, + headers: { + 'User-Agent': `@${botInfo.username}-node-telegram-bot` + } + }); + + const track = response.data.recenttracks.track[0]; + + if (!track) { + const noRecent = Strings.lastFm.noRecentTracks.replace('{lastfmUser}', lastfmUser); + return ctx.reply(noRecent, { + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }; + + const trackName = track.name; + const artistName = track.artist['#text']; + const nowPlaying = track['@attr'] && track['@attr'].nowplaying ? Strings.varStrings.varIs : Strings.varStrings.varWas; + const albumMbid = track.album.mbid; + + let imageUrl = ""; + + if (albumMbid) { + imageUrl = await getFromMusicBrainz(albumMbid); + } + + if (!imageUrl) { + imageUrl = getFromLast(track); + } + + if (imageUrl == genericImg) { + imageUrl = ""; + } + + const trackUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}/_/${encodeURIComponent(trackName)}`; + const artistUrl = `https://www.last.fm/music/${encodeURIComponent(artistName)}`; + const userUrl = `https://www.last.fm/user/${encodeURIComponent(lastfmUser)}`; + + let num_plays = 0; + try { + const response_plays = await axios.get(scrobbler_url, { + params: { + method: 'track.getInfo', + api_key, + track: trackName, + artist: artistName, + username: lastfmUser, + format: 'json', + }, + headers: { + 'User-Agent': `@${botInfo.username}-node-telegram-bot` + } + }); + + num_plays = response_plays.data.track.userplaycount; + } catch (err) { + console.log(err) + const message = Strings.lastFm.apiErr + .replace("{lastfmUser}", `[${lastfmUser}](${userUrl})`) + .replace("{err}", err); + ctx.reply(message, { + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }; + + let message = Strings.lastFm.listeningTo + .replace("{lastfmUser}", `[${lastfmUser}](${userUrl})`) + .replace("{nowPlaying}", nowPlaying) + .replace("{trackName}", `[${trackName}](${trackUrl})`) + .replace("{artistName}", `[${artistName}](${artistUrl})`) + + if (`${num_plays}` !== "0" && `${num_plays}` !== "1" && `${num_plays}` !== "2" && `${num_plays}` !== "3") { + message = message + .replace("{playCount}", Strings.lastFm.playCount) + .replace("{plays}", `${num_plays}`); + } else { + message = message + .replace("{playCount}", Strings.varStrings.varTo); + }; + + if (imageUrl) { + ctx.replyWithPhoto(imageUrl, { + caption: message, + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + } else { + ctx.reply(message, { + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(ctx.message?.message_id ? { reply_parameters: { message_id: ctx.message.message_id } } : {}) + }); + }; + } catch (err) { + const userUrl = `https://www.last.fm/user/${encodeURIComponent(lastfmUser)}`; + const message = Strings.lastFm.apiErr + .replace("{lastfmUser}", `[${lastfmUser}](${userUrl})`) + .replace("{err}", err); + ctx.reply(message, { + parse_mode: "Markdown", + disable_web_page_preview: true, + ...(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/telegram/commands/modarchive.ts b/telegram/commands/modarchive.ts new file mode 100755 index 0000000..5d451a6 --- /dev/null +++ b/telegram/commands/modarchive.ts @@ -0,0 +1,88 @@ +import Resources from '../props/resources.json'; +import axios from 'axios'; +import fs from 'fs'; +import path from 'path'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +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); + +interface ModuleResult { + filePath: string; + fileName: string; +} + +async function downloadModule(moduleId: string): Promise { + try { + const downloadUrl = `${Resources.modArchiveApi}${moduleId}`; + const response = await axios({ + url: downloadUrl, + method: 'GET', + responseType: 'stream', + }); + const disposition = response.headers['content-disposition']; + let fileName = moduleId; + if (disposition && disposition.includes('filename=')) { + fileName = disposition + .split('filename=')[1] + .split(';')[0] + .replace(/['"]/g, ''); + } + const filePath = path.join(__dirname, fileName); + const writer = fs.createWriteStream(filePath); + response.data.pipe(writer); + return new Promise((resolve, reject) => { + writer.on('finish', () => resolve({ filePath, fileName })); + writer.on('error', reject); + }); + } catch (error) { + return null; + } +} + +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_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/telegram/commands/ponyapi.ts b/telegram/commands/ponyapi.ts new file mode 100755 index 0000000..2bbc841 --- /dev/null +++ b/telegram/commands/ponyapi.ts @@ -0,0 +1,286 @@ +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 { 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); + +interface Character { + id: string; + name: string; + alias: string; + url: string; + sex: string; + residence: string; + occupation: string; + kind: string; + image: string[]; +} + +interface Episode { + id: string; + name: string; + image: string; + url: string; + season: string; + episode: string; + overall: string; + airdate: string; + storyby: string; + writtenby: string; + storyboard: string; +} + +interface Comic { + id: string; + name: string; + series: string; + image: string; + url: string; + writer: string; + artist: string; + colorist: string; + letterer: string; + editor: string; +} + +function capitalizeFirstLetter(letter: string) { + return letter.charAt(0).toUpperCase() + letter.slice(1); +} + +function sendReply(ctx: Context, text: string, reply_to_message_id?: number) { + return ctx.reply(text, { + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +} + +function sendPhoto(ctx: Context, photo: string, caption: string, reply_to_message_id?: number) { + return ctx.replyWithPhoto(photo, { + caption, + parse_mode: 'Markdown', + ...(reply_to_message_id ? { reply_parameters: { message_id: reply_to_message_id } } : {}) + }); +} + +export default (bot: Telegraf, db) => { + bot.command("mlp", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'mlp-content')) return; + + const Strings = getStrings(languageCode(ctx)); + const reply_to_message_id = replyToMessageId(ctx); + sendReply(ctx, Strings.ponyApi.helpDesc, reply_to_message_id); + }); + + bot.command("mlpchar", spamwatchMiddleware, async (ctx: Context & { message: { text: string } }) => { + if (await isCommandDisabled(ctx, db, 'mlp-content')) return; + + const { message } = ctx; + const reply_to_message_id = replyToMessageId(ctx); + const Strings = getStrings(languageCode(ctx) || 'en'); + const userInput = message.text.split(' ').slice(1).join(' ').trim().replace(/\s+/g, '+'); + const { noCharName } = Strings.ponyApi; + + 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); + const apiUrl = `${Resources.ponyApi}/character/${capitalizedInput}`; + + try { + const response = await axios(apiUrl); + const data = response.data.data; + if (Array.isArray(data) && data.length > 0) { + const character = data[0]; + const aliases = Array.isArray(character.alias) + ? character.alias.join(', ') + : character.alias || Strings.varStrings.varNone; + const result = Strings.ponyApi.charRes + .replace("{id}", character.id) + .replace("{name}", character.name) + .replace("{alias}", aliases) + .replace("{url}", character.url) + .replace("{sex}", character.sex) + .replace("{residence}", character.residence ? character.residence.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{occupation}", character.occupation ? character.occupation.replace(/\n/g, ' / ') : Strings.varStrings.varNone) + .replace("{kind}", Array.isArray(character.kind) ? character.kind.join(', ') : Strings.varStrings.varNone); + sendPhoto(ctx, character.image[0], result, reply_to_message_id); + } else { + sendReply(ctx, Strings.ponyApi.noCharFound, reply_to_message_id); + } + } catch (error: any) { + const message = Strings.ponyApi.apiErr.replace('{error}', error.message || 'Unknown error'); + sendReply(ctx, message, reply_to_message_id); + } + }); + + 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); + + const { noEpisodeNum } = Strings.ponyApi + + if (verifyInput(ctx, userInput, noEpisodeNum, true)) { + 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 { + const response = await axios(apiUrl); + const episodeArray: Episode[] = []; + + if (Array.isArray(response.data.data)) { + response.data.data.forEach((episode: Episode) => { + episodeArray.push({ + id: episode.id, + name: episode.name, + image: episode.image, + url: episode.url, + season: episode.season, + episode: episode.episode, + overall: episode.overall, + airdate: episode.airdate, + storyby: episode.storyby ? episode.storyby.replace(/\n/g, ' / ') : Strings.varStrings.varNone, + writtenby: episode.writtenby ? episode.writtenby.replace(/\n/g, ' / ') : Strings.varStrings.varNone, + storyboard: episode.storyboard ? episode.storyboard.replace(/\n/g, ' / ') : Strings.varStrings.varNone, + }); + }); + }; + + if (episodeArray.length > 0) { + const result = Strings.ponyApi.epRes + .replace("{id}", episodeArray[0].id) + .replace("{name}", episodeArray[0].name) + .replace("{url}", episodeArray[0].url) + .replace("{season}", episodeArray[0].season) + .replace("{episode}", episodeArray[0].episode) + .replace("{overall}", episodeArray[0].overall) + .replace("{airdate}", episodeArray[0].airdate) + .replace("{storyby}", episodeArray[0].storyby) + .replace("{writtenby}", episodeArray[0].writtenby) + .replace("{storyboard}", episodeArray[0].storyboard); + + ctx.replyWithPhoto(episodeArray[0].image, { + caption: `${result}`, + parse_mode: 'Markdown', + ...(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_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 } } : {}) + }); + }; + }); + + 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); + + const { noComicName } = Strings.ponyApi + + if (verifyInput(ctx, userInput, noComicName)) { + 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 { + const response = await axios(apiUrl); + const comicArray: Comic[] = []; + if (Array.isArray(response.data.data)) { + response.data.data.forEach(comic => { + let letterers: string[] = []; + if (comic.letterer) { + if (typeof comic.letterer === 'string') { + letterers.push(comic.letterer); + } else if (Array.isArray(comic.letterer)) { + letterers = letterers.concat(comic.letterer); + } + } + comicArray.push({ + id: comic.id, + name: comic.name, + series: comic.series, + image: comic.image, + url: comic.url, + writer: comic.writer ? comic.writer.replace(/\n/g, ' / ') : Strings.varStrings.varNone, + artist: comic.artist ? comic.artist.replace(/\n/g, ' / ') : Strings.varStrings.varNone, + colorist: comic.colorist ? comic.colorist.replace(/\n/g, ' / ') : Strings.varStrings.varNone, + letterer: letterers.length > 0 ? letterers.join(', ') : Strings.varStrings.varNone, + editor: comic.editor + }); + }); + }; + + if (comicArray.length > 0) { + const result = Strings.ponyApi.comicRes + .replace("{id}", comicArray[0].id) + .replace("{name}", comicArray[0].name) + .replace("{series}", comicArray[0].series) + .replace("{url}", comicArray[0].url) + .replace("{writer}", comicArray[0].writer) + .replace("{artist}", comicArray[0].artist) + .replace("{colorist}", comicArray[0].colorist) + .replace("{letterer}", comicArray[0].letterer) + .replace("{editor}", comicArray[0].editor); + + ctx.replyWithPhoto(comicArray[0].image, { + caption: `${result}`, + parse_mode: 'Markdown', + ...(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_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 } } : {}) + }); + }; + }); +}; diff --git a/telegram/commands/quotes.ts b/telegram/commands/quotes.ts new file mode 100755 index 0000000..4c59f2a --- /dev/null +++ b/telegram/commands/quotes.ts @@ -0,0 +1,32 @@ +/* +import Resources from '../props/resources.json'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import escape from 'markdown-escape'; +import axios from 'axios'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +export default (bot) => { + bot.command("quote", spamwatchMiddleware, async (ctx) => { + const Strings = getStrings(ctx.from.language_code); + + try { + const response = await axios.get(Resources.quoteApi); + const data = response.data; + + ctx.reply(escape(`${escape(Strings.quoteResult)}\n> *${escape(data.quote)}*\n_${escape(data.author)}_`), { + reply_to_message_id: ctx.message.message_id, + parse_mode: 'Markdown' + }); + } catch (error) { + console.error(error); + ctx.reply(Strings.quoteErr, { + reply_to_message_id: ctx.message.id, + parse_mode: 'MarkdownV2' + }); + }; + }); +}; +*/ \ No newline at end of file diff --git a/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/telegram/commands/weather.ts b/telegram/commands/weather.ts new file mode 100755 index 0000000..26a1b04 --- /dev/null +++ b/telegram/commands/weather.ts @@ -0,0 +1,124 @@ +// Ported and improved from BubbalooTeam's PyCoala bot +// Copyright (c) 2024 BubbalooTeam. (https://github.com/BubbalooTeam) +// Minor code changes by lucmsilva (https://github.com/lucmsilva651) + +import Resources from '../props/resources.json'; +import axios from 'axios'; +import { getStrings } from '../plugins/checklang'; +import { isOnSpamWatch } from '../spamwatch/spamwatch'; +import spamwatchMiddlewareModule from '../spamwatch/Middleware'; +import verifyInput from '../plugins/verifyInput'; +import { Context, Telegraf } from 'telegraf'; +import { isCommandDisabled } from '../utils/check-command-disabled'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +const statusEmojis = { + 0: '⛈', 1: '⛈', 2: '⛈', 3: '⛈', 4: '⛈', 5: '🌨', 6: '🌨', 7: '🌨', + 8: '🌨', 9: '🌨', 10: '🌨', 11: '🌧', 12: '🌧', 13: '🌨', 14: '🌨', + 15: '🌨', 16: '🌨', 17: '⛈', 18: '🌧', 19: '🌫', 20: '🌫', 21: '🌫', + 22: '🌫', 23: '🌬', 24: '🌬', 25: '🌨', 26: '☁️', 27: '🌥', 28: '🌥', + 29: '⛅️', 30: '⛅️', 31: '🌙', 32: '☀️', 33: '🌤', 34: '🌤', 35: '⛈', + 36: '🔥', 37: '🌩', 38: '🌩', 39: '🌧', 40: '🌧', 41: '❄️', 42: '❄️', + 43: '❄️', 44: 'n/a', 45: '🌧', 46: '🌨', 47: '🌩' +}; + +const getStatusEmoji = (statusCode: number) => statusEmojis[statusCode] || 'n/a'; + +function getLocaleUnit(countryCode: string) { + const fahrenheitCountries: string[] = ['US', 'BS', 'BZ', 'KY', 'LR']; + + if (fahrenheitCountries.includes(countryCode)) { + return { temperatureUnit: 'F', speedUnit: 'mph', apiUnit: 'e' }; + } else { + return { temperatureUnit: 'C', speedUnit: 'km/h', apiUnit: 'm' }; + } +} + +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 Strings = getStrings(userLang); + const userInput = ctx.message.text.split(' ').slice(1).join(' '); + const { provideLocation } = Strings.weatherStatus + + if (verifyInput(ctx, userInput, provideLocation)) { + return; + } + + const location: string = userInput; + const apiKey: string = process.env.weatherKey || ''; + + if (!apiKey || apiKey === "InsertYourWeatherDotComApiKeyHere") { + return ctx.reply(Strings.weatherStatus.apiKeyErr, { + parse_mode: "Markdown", + ...({ reply_to_message_id }) + }); + } + + try { + // TODO: this also needs to be sanitized and validated + const locationResponse = await axios.get(`${Resources.weatherApi}/location/search`, { + params: { + apiKey: apiKey, + format: 'json', + language: userLang, + query: location, + }, + }); + + const locationData = locationResponse.data.location; + if (!locationData || !locationData.address) { + return ctx.reply(Strings.weatherStatus.invalidLocation, { + parse_mode: "Markdown", + ...({ reply_to_message_id }) + }); + } + + const addressFirst = locationData.address[0]; + const latFirst = locationData.latitude[0]; + const lonFirst = locationData.longitude[0]; + const countryCode = locationData.countryCode[0]; + const { temperatureUnit, speedUnit, apiUnit } = getLocaleUnit(countryCode); + + const weatherResponse = await axios.get(`${Resources.weatherApi}/aggcommon/v3-wx-observations-current`, { + params: { + apiKey: apiKey, + format: 'json', + language: userLang, + geocode: `${latFirst},${lonFirst}`, + units: apiUnit, + }, + }); + + const weatherData = weatherResponse.data['v3-wx-observations-current']; + const { temperature, temperatureFeelsLike, relativeHumidity, windSpeed, iconCode, wxPhraseLong } = weatherData; + + const weatherMessage = Strings.weatherStatus.resultMsg + .replace('{addressFirst}', addressFirst) + .replace('{getStatusEmoji(iconCode)}', getStatusEmoji(iconCode)) + .replace('{wxPhraseLong}', wxPhraseLong) + .replace('{temperature}', temperature) + .replace('{temperatureFeelsLike}', temperatureFeelsLike) + .replace('{temperatureUnit}', temperatureUnit) + .replace('{temperatureUnit2}', temperatureUnit) + .replace('{relativeHumidity}', relativeHumidity) + .replace('{windSpeed}', windSpeed) + .replace('{speedUnit}', speedUnit); + + ctx.reply(weatherMessage, { + parse_mode: "Markdown", + ...({ reply_to_message_id }) + }); + } catch (error) { + const message = Strings.weatherStatus.apiErr.replace('{error}', error.message); + ctx.reply(message, { + parse_mode: "Markdown", + ...({ reply_to_message_id }) + }); + } + }); +}; \ No newline at end of file diff --git a/telegram/commands/wiki.ts b/telegram/commands/wiki.ts new file mode 100755 index 0000000..bec7df1 --- /dev/null +++ b/telegram/commands/wiki.ts @@ -0,0 +1,41 @@ +/* +import axios from "axios"; +import { Context, Telegraf } from "telegraf"; +import { replyToMessageId } from "../utils/reply-to-message-id"; + +function capitalizeFirstLetter(string: string) { + return string.charAt(0).toUpperCase() + string.slice(1); +} + +function mediaWikiToMarkdown(input: string) { + input = input.replace(/===(.*?)===/g, '*$1*'); + input = input.replace(/==(.*?)==/g, '*$1*'); + input = input.replace(/=(.*?)=/g, '*$1*'); + input = input.replace(/'''(.*?)'''/g, '**$1**'); + input = input.replace(/''(.*?)''/g, '_$1_'); + input = input.replace(/^\*\s/gm, '- '); + input = input.replace(/^\#\s/gm, '1. '); + input = input.replace(/{{Quote(.*?)}}/g, "```\n$1```\n"); + input = input.replace(/\[\[(.*?)\|?(.*?)\]\]/g, (_, link, text) => { + const sanitizedLink = link.replace(/ /g, '_'); + return text ? `[${text}](${sanitizedLink})` : `[${sanitizedLink}](${sanitizedLink})`; + }); + input = input.replace(/\[\[File:(.*?)\|.*?\]\]/g, '![$1](https://en.wikipedia.org/wiki/File:$1)'); + + return input; +} + +export default (bot: Telegraf) => { + bot.command("wiki", async (ctx) => { + const userInput = capitalizeFirstLetter(ctx.message.text.split(' ')[1]); + const apiUrl = `https://en.wikipedia.org/w/index.php?title=${userInput}&action=raw`; + const response = await axios(apiUrl, { headers: { 'Accept': "text/plain" } }); + const convertedResponse = response.data.replace(/<\/?div>/g, "").replace(/{{Infobox.*?}}/s, ""); + + const result = mediaWikiToMarkdown(convertedResponse).slice(0, 2048); + const reply_to_message_id = replyToMessageId(ctx); + + ctx.reply(result, { parse_mode: 'Markdown', ...({ reply_to_message_id, disable_web_page_preview: true }) }); + }); +}; +*/ \ No newline at end of file diff --git a/telegram/commands/youtube.ts b/telegram/commands/youtube.ts new file mode 100755 index 0000000..5b20029 --- /dev/null +++ b/telegram/commands/youtube.ts @@ -0,0 +1,256 @@ +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'; +import * as ytUrl from 'youtube-url'; + +const spamwatchMiddleware = spamwatchMiddlewareModule(isOnSpamWatch); + +const ytDlpPaths = { + linux: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp'), + win32: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp.exe'), + darwin: path.resolve(__dirname, '../plugins/yt-dlp/yt-dlp_macos'), +}; + +const getYtDlpPath = () => { + const platform = os.platform(); + return ytDlpPaths[platform] || ytDlpPaths.linux; +}; + + +const ffmpegPaths = { + linux: '/usr/bin/ffmpeg', + win32: path.resolve(__dirname, '../plugins/ffmpeg/bin/ffmpeg.exe'), +}; + +const getFfmpegPath = () => { + const platform = os.platform(); + return ffmpegPaths[platform] || ffmpegPaths.linux; +}; + +const downloadFromYoutube = async (command: string, args: string[]): Promise<{ stdout: string; stderr: string }> => { + return new Promise((resolve, reject) => { + execFile(command, args, (error, stdout, stderr) => { + if (error) { + reject({ error, stdout, stderr }); + } else { + resolve({ stdout, stderr }); + } + }); + }); +}; + +const getApproxSize = async (command: string, videoUrl: string): Promise => { + let args: string[] = []; + if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { + args = [videoUrl, '--compat-opt', 'manifest-filesize-approx', '-O', 'filesize_approx', '--cookies', path.resolve(__dirname, "../props/cookies.txt")]; + } else { + args = [videoUrl, '--compat-opt', 'manifest-filesize-approx', '-O', 'filesize_approx']; + } + try { + const { stdout } = await downloadFromYoutube(command, args); + const sizeInBytes = parseInt(stdout.trim(), 10); + if (!isNaN(sizeInBytes)) { + return sizeInBytes / (1024 * 1024); + } else { + return 0; + } + } catch (error) { + throw error; + } +}; + +const isValidUrl = (url: string): boolean => { + try { + new URL(url); + return true; + } catch { + return false; + } +}; + +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; + const videoUrl: string = ctx.message.text.split(' ').slice(1).join(' '); + const videoIsYoutube: boolean = ytUrl.valid(videoUrl); + const randId: string = Math.random().toString(36).substring(2, 15); + const mp4File: string = `tmp/${userId}-${randId}.mp4`; + const tempMp4File: string = `tmp/${userId}-${randId}.f137.mp4`; + const tempWebmFile: string = `tmp/${userId}-${randId}.f251.webm`; + let cmdArgs: string = ""; + const dlpCommand: string = ytDlpPath; + const ffmpegPath: string = getFfmpegPath(); + const ffmpegArgs: string[] = ['-i', tempMp4File, '-i', tempWebmFile, '-c:v copy -c:a copy -strict -2', mp4File]; + + /* + for now, no checking is done for the video url + yt-dlp should handle the validation, though it supports too many sites to hard-code + */ + if (!videoUrl) { + return ctx.reply(Strings.ytDownload.noLink, { + parse_mode: "Markdown", + disable_web_page_preview: true, + reply_to_message_id: ctx.message.message_id + }); + } + + // make sure its a valid url + if (!isValidUrl(videoUrl)) { + console.log("[!] Invalid URL:", videoUrl) + return ctx.reply(Strings.ytDownload.noLink, { + parse_mode: "Markdown", + disable_web_page_preview: true, + reply_to_message_id: ctx.message.message_id + }); + } + + console.log(`\nDownload Request:\nURL: ${videoUrl}\nYOUTUBE: ${videoIsYoutube}\n`) + + if (fs.existsSync(path.resolve(__dirname, "../props/cookies.txt"))) { + cmdArgs = "--max-filesize 2G --no-playlist --cookies telegram/props/cookies.txt --merge-output-format mp4 -o"; + } else { + cmdArgs = `--max-filesize 2G --no-playlist --merge-output-format mp4 -o`; + } + + try { + const downloadingMessage = await ctx.reply(Strings.ytDownload.checkingSize, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + + if (fs.existsSync(ytDlpPath)) { + const approxSizeInMB = await Promise.race([ + getApproxSize(ytDlpPath, videoUrl), + ]); + + if (approxSizeInMB > 50) { + console.log("[!] Video size exceeds 50MB:", approxSizeInMB) + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.uploadLimit, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + + return; + } + + console.log("[i] Downloading video...") + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.downloadingVid, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + + const dlpArgs = [videoUrl, ...cmdArgs.split(' '), mp4File]; + await downloadFromYoutube(dlpCommand, dlpArgs); + + console.log("[i] Uploading video...") + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.uploadingVid, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + + if (fs.existsSync(tempMp4File)) { + await downloadFromYoutube(ffmpegPath, ffmpegArgs); + } + + if (fs.existsSync(mp4File)) { + const message = Strings.ytDownload.msgDesc.replace("{userMention}", `[${ctx.from.first_name}](tg://user?id=${userId})`) + + try { + await ctx.replyWithVideo({ + source: mp4File + }, { + caption: message, + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + + fs.unlinkSync(mp4File); + } catch (error) { + if (error.response.description.includes("Request Entity Too Large")) { + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.uploadLimit, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + } else { + const errMsg = Strings.ytDownload.uploadErr.replace("{error}", error) + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + errMsg, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + }; + + fs.unlinkSync(mp4File); + } + } else { + await ctx.reply(mp4File, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + } + } else { + await ctx.telegram.editMessageText( + ctx.chat.id, + downloadingMessage.message_id, + null, + Strings.ytDownload.libNotFound, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }, + ); + } + console.log("[i] Request completed\n") + } catch (error) { + let errMsg = Strings.ytDownload.uploadErr + + if (error.stderr.includes("--cookies-from-browser")) { + console.log("[!] Ratelimited by video provider:", error.stderr) + errMsg = Strings.ytDownload.botDetection + if (error.stderr.includes("youtube")) { + errMsg = Strings.ytDownload.botDetection.replace("video provider", "YouTube") + } + } else { + console.log("[!]", error.stderr) + } + + // will no longer edit the message as the message context is not outside the try block + await ctx.reply(errMsg, { + parse_mode: 'Markdown', + reply_to_message_id: ctx.message.message_id, + }); + } + }); +}; \ No newline at end of file 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 `