├── src ├── __init__.py ├── referral_system.py ├── escrow │ ├── __init__.py │ ├── escrow_offer.py │ └── blockchain │ │ ├── golos_blockchain.py │ │ ├── cyber_blockchain.py │ │ └── __init__.py ├── config.py ├── money.py ├── i18n.py ├── app.py ├── whitelist.py ├── notifications.py ├── states.py ├── handlers │ ├── __init__.py │ ├── support.py │ ├── cashback.py │ ├── start_menu.py │ └── base.py ├── database.py └── bot.py ├── secrets ├── dbpassword ├── tbtoken └── escrow.json ├── requirements.txt ├── docs ├── source │ ├── modules.rst │ ├── src.escrow.blockchain.rst │ ├── src.escrow.rst │ ├── src.handlers.rst │ └── src.rst ├── index.rst ├── Makefile ├── make.bat └── conf.py ├── requirements-dev.txt ├── requirements-escrow.txt ├── __main__.py ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── workflows │ └── pre-commit.yml └── dependabot.yml ├── setup.cfg ├── mongo-init.sh ├── .dockerignore ├── .env.example ├── Dockerfile ├── docker-compose.yml ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── .gitignore ├── README.md ├── CLA.md └── locale ├── tr └── LC_MESSAGES │ └── bot.po ├── fr └── LC_MESSAGES │ └── bot.po └── en └── LC_MESSAGES └── bot.po /src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /secrets/dbpassword: -------------------------------------------------------------------------------- 1 | put your secret database password here 2 | -------------------------------------------------------------------------------- /secrets/tbtoken: -------------------------------------------------------------------------------- 1 | 000000000:ABCDEFGHIJKLMNOPQRSTUVWXYZ123456789 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram==2.8 2 | emoji==1.4.2 3 | motor==2.5.0 4 | pymongo==3.12.0 5 | requests==2.26.0 6 | -------------------------------------------------------------------------------- /docs/source/modules.rst: -------------------------------------------------------------------------------- 1 | tellerbot 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | src 8 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pre-commit==2.11.1 2 | pytest==5.4.3 3 | pytest-asyncio==0.12.0 4 | sphinx==3.5.3 5 | sphinx_rtd_theme==0.5.1 6 | -------------------------------------------------------------------------------- /requirements-escrow.txt: -------------------------------------------------------------------------------- 1 | git+https://github.com/eosnewyork/eospy.git@ed55d652f5dcc9e45917273e6bc14b37791e772d 2 | golos-python==1.1.0 3 | -------------------------------------------------------------------------------- /__main__.py: -------------------------------------------------------------------------------- 1 | """Main entry point. 2 | 3 | Start bot by executing root of the repository. 4 | """ 5 | from src.app import main 6 | 7 | main() 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Closes # 2 | 3 | ## Proposed Changes 4 | 5 | - 6 | - 7 | - 8 | 9 | By sending this contribution I hereby agree to the terms of [Contributor License Agreement](https://github.com/fincubator/tellerbot/blob/master/CLA.md). 10 | -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: pre-commit 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [master] 7 | 8 | jobs: 9 | pre-commit: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-python@v2 14 | - uses: pre-commit/action@v2.0.0 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Black recommended configuration 3 | max_line_length = 88 4 | ignore = W503, E203 5 | exclude = docs/conf.py 6 | 7 | [pydocstyle] 8 | add-ignore = D100,D102,D104,D202,D213 9 | add-select = D212 10 | # Allow missing docstrings in blockchain's abstract class children 11 | match-dir = (?!^\.|^blockchain$).* 12 | -------------------------------------------------------------------------------- /mongo-init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mongo -- "${MONGO_INITDB_DATABASE:-${DATABASE_NAME}}" < 2.10.1" 15 | - dependency-name: aiogram 16 | versions: 17 | - "> 2.8" 18 | - dependency-name: pytest 19 | versions: 20 | - "> 5.4.3" 21 | - dependency-name: pre-commit 22 | versions: 23 | - 2.10.0 24 | - 2.12.0 25 | - dependency-name: sphinx 26 | versions: 27 | - 3.5.0 28 | - 3.5.2 29 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Database data 2 | data/ 3 | 4 | # Git 5 | .git/ 6 | .gitignore 7 | .gitattributes 8 | 9 | # Docker 10 | docker-compose.yml 11 | .dockerignore 12 | 13 | # Markdown files 14 | **/*.md 15 | 16 | # Development files 17 | .pre-commit-config.yml 18 | requirements-dev.txt 19 | setup.cfg 20 | 21 | # GitHub 22 | .github/ 23 | 24 | # Examples 25 | *.example 26 | 27 | # Logs 28 | **/*.log 29 | 30 | # Byte-compiled / optimized / DLL files 31 | **/__pycache__/ 32 | **/*.pyc 33 | 34 | # Unit test / coverage reports 35 | tests/ 36 | .pytest_cache/ 37 | 38 | # Translations 39 | **/*.mo 40 | **/*.pot 41 | 42 | # Sphinx documentation 43 | docs/ 44 | 45 | # Environments 46 | .env 47 | 48 | # mypy 49 | .mypy_cache/ 50 | .dmypy.json 51 | dmypy.json 52 | 53 | # IDEA folder 54 | .idea/ 55 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Connection 2 | TOKEN_FILENAME=/run/secrets/tbtoken 3 | SET_WEBHOOK=true # Use long polling if set to false 4 | INTERNAL_HOST=0.0.0.0 5 | SERVER_HOST=example.com 6 | SERVER_PORT=5000 7 | WEBHOOK_PATH=/tellerbot/webhook 8 | DATABASE_HOST=database 9 | DATABASE_PORT=27017 10 | DATABASE_USERNAME=tellerbot 11 | DATABASE_PASSWORD_FILENAME=/run/secrets/dbpassword 12 | DATABASE_NAME=tellerbot 13 | 14 | # Logging 15 | LOGGER_LEVEL=INFO 16 | DATABASE_LOGGING_ENABLED=true 17 | 18 | # Chat IDs 19 | SUPPORT_CHAT_ID=-123456789 20 | EXCEPTIONS_CHAT_ID=-1234567890123 21 | 22 | # Orders 23 | ORDERS_COUNT=10 24 | ORDERS_LIMIT_HOURS=24 25 | ORDERS_LIMIT_COUNT=10 26 | ORDER_DURATION_LIMIT=30 27 | 28 | # Escrow 29 | ESCROW_ENABLED=false 30 | ESCROW_FEE_PERCENTS=5 31 | CHECK_TIMEOUT_HOURS=24 32 | ESCROW_FILENAME=/run/secrets/escrow.json 33 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim-buster 2 | 3 | LABEL mantainer="alfred richardsn " 4 | 5 | ARG ESCROW_ENABLED 6 | 7 | RUN if test "$ESCROW_ENABLED" = true; then \ 8 | apt-get update && apt-get install --yes --no-install-recommends git; \ 9 | else exit 0; fi 10 | 11 | ARG USER=tellerbot 12 | ARG GROUP=tellerbot 13 | 14 | ENV HOME /home/$USER 15 | 16 | RUN groupadd -g 999 $GROUP \ 17 | && useradd -g $GROUP -u 999 -l -s /sbin/nologin -m -d $HOME $USER 18 | WORKDIR $HOME 19 | USER $USER:$GROUP 20 | 21 | COPY --chown=999:999 requirements.txt requirements-escrow.txt ./ 22 | ENV PATH $PATH:$HOME/.local/bin 23 | RUN pip install --user --no-cache-dir --requirement requirements.txt \ 24 | && if test "$ESCROW_ENABLED" = true; then \ 25 | pip install --user --no-cache-dir --requirement requirements-escrow.txt; \ 26 | else exit 0; fi 27 | 28 | COPY --chown=999:999 locale/ locale/ 29 | RUN pybabel compile --directory=locale/ --domain=bot 30 | 31 | COPY --chown=999:999 . . 32 | 33 | ENTRYPOINT ["python", "."] 34 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | 3 | services: 4 | tellerbot: 5 | build: 6 | context: . 7 | args: 8 | ESCROW_ENABLED: ${ESCROW_ENABLED} 9 | container_name: tellerbot 10 | depends_on: 11 | - database 12 | env_file: 13 | - .env 14 | restart: on-failure 15 | volumes: 16 | - ${DATABASE_PASSWORD_FILENAME}:${DATABASE_PASSWORD_FILENAME}:ro 17 | - ${TOKEN_FILENAME}:${TOKEN_FILENAME}:ro 18 | - ${ESCROW_FILENAME}:${ESCROW_FILENAME}:ro 19 | ports: 20 | - ${SERVER_PORT}:${SERVER_PORT} 21 | 22 | database: 23 | image: mongo:latest 24 | container_name: ${DATABASE_HOST} 25 | environment: 26 | - MONGO_INITDB_ROOT_USERNAME=${DATABASE_USERNAME} 27 | - MONGO_INITDB_ROOT_PASSWORD_FILE=${DATABASE_PASSWORD_FILENAME} 28 | - MONGO_INITDB_DATABASE=${DATABASE_NAME} 29 | command: mongod --port ${DATABASE_PORT} --quiet 30 | ports: 31 | - ${DATABASE_PORT}:${DATABASE_PORT} 32 | volumes: 33 | - ${DATABASE_PASSWORD_FILENAME}:${DATABASE_PASSWORD_FILENAME}:ro 34 | - ./mongo-init.sh:/docker-entrypoint-initdb.d/mongo-init.sh:ro 35 | - ./data/db:/data/db 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v3.4.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-added-large-files 8 | - id: requirements-txt-fixer 9 | - repo: https://github.com/psf/black 10 | rev: 20.8b1 11 | hooks: 12 | - id: black 13 | - repo: https://github.com/asottile/reorder_python_imports 14 | rev: v2.3.6 15 | hooks: 16 | - id: reorder-python-imports 17 | - repo: https://github.com/pre-commit/mirrors-mypy 18 | rev: v0.790 19 | hooks: 20 | - id: mypy 21 | - repo: https://github.com/pycqa/bandit 22 | rev: 1.7.0 23 | hooks: 24 | - id: bandit 25 | - repo: https://gitlab.com/pycqa/flake8 26 | rev: 3.8.4 27 | hooks: 28 | - id: flake8 29 | additional_dependencies: [ 30 | flake8-annotations-complexity, 31 | flake8_builtins, 32 | flake8-eradicate, 33 | flake8_pep3101, 34 | flake8-print, 35 | ] 36 | - repo: https://github.com/pycqa/pydocstyle 37 | rev: 5.1.1 38 | hooks: 39 | - id: pydocstyle 40 | -------------------------------------------------------------------------------- /docs/source/src.handlers.rst: -------------------------------------------------------------------------------- 1 | src.handlers package 2 | ==================== 3 | 4 | Submodules 5 | ---------- 6 | 7 | src.handlers.base module 8 | ------------------------ 9 | 10 | .. automodule:: src.handlers.base 11 | :members: 12 | :undoc-members: 13 | :show-inheritance: 14 | 15 | src.handlers.creation module 16 | ---------------------------- 17 | 18 | .. automodule:: src.handlers.creation 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | src.handlers.escrow module 24 | -------------------------- 25 | 26 | .. automodule:: src.handlers.escrow 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | src.handlers.order module 32 | ------------------------- 33 | 34 | .. automodule:: src.handlers.order 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | src.handlers.start\_menu module 40 | ------------------------------- 41 | 42 | .. automodule:: src.handlers.start_menu 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | src.handlers.support module 48 | --------------------------- 49 | 50 | .. automodule:: src.handlers.support 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | 56 | Module contents 57 | --------------- 58 | 59 | .. automodule:: src.handlers 60 | :members: 61 | :undoc-members: 62 | :show-inheritance: 63 | -------------------------------------------------------------------------------- /docs/source/src.rst: -------------------------------------------------------------------------------- 1 | src package 2 | =========== 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | 9 | src.escrow 10 | src.handlers 11 | 12 | Submodules 13 | ---------- 14 | 15 | src.app module 16 | -------------- 17 | 18 | .. automodule:: src.app 19 | :members: 20 | :undoc-members: 21 | :show-inheritance: 22 | 23 | src.bot module 24 | -------------- 25 | 26 | .. automodule:: src.bot 27 | :members: 28 | :undoc-members: 29 | :show-inheritance: 30 | 31 | src.database module 32 | ------------------- 33 | 34 | .. automodule:: src.database 35 | :members: 36 | :undoc-members: 37 | :show-inheritance: 38 | 39 | src.i18n module 40 | --------------- 41 | 42 | .. automodule:: src.i18n 43 | :members: 44 | :undoc-members: 45 | :show-inheritance: 46 | 47 | src.money module 48 | ---------------- 49 | 50 | .. automodule:: src.money 51 | :members: 52 | :undoc-members: 53 | :show-inheritance: 54 | 55 | src.notifications module 56 | ------------------------ 57 | 58 | .. automodule:: src.notifications 59 | :members: 60 | :undoc-members: 61 | :show-inheritance: 62 | 63 | src.states module 64 | ----------------- 65 | 66 | .. automodule:: src.states 67 | :members: 68 | :undoc-members: 69 | :show-inheritance: 70 | 71 | 72 | src.whitelist module 73 | -------------------- 74 | 75 | .. automodule:: src.whitelist 76 | :members: 77 | :undoc-members: 78 | :show-inheritance: 79 | 80 | 81 | Module contents 82 | --------------- 83 | 84 | .. automodule:: src 85 | :members: 86 | :undoc-members: 87 | :show-inheritance: 88 | -------------------------------------------------------------------------------- /src/referral_system.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019, 2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | from decimal import Decimal 18 | from typing import Mapping 19 | 20 | PERSONAL_CATEGORY: Mapping[int, int] = { 21 | 1: 1, 22 | 10: 2, 23 | 100: 5, 24 | 1_000: 10, 25 | 10_000: 20, 26 | 100_000: 40, 27 | } 28 | 29 | REFERRED_CATEGORY: Mapping[int, int] = { 30 | 5: 1, 31 | 50: 2, 32 | 500: 4, 33 | 5_000: 8, 34 | 50_000: 16, 35 | 500_000: 30, 36 | } 37 | 38 | REFERRED_BY_REFERALS_CATEGORY: Mapping[int, int] = { 39 | 100: 1, 40 | 1_000: 2, 41 | 10_000: 4, 42 | 100_000: 8, 43 | 1_000_000: 20, 44 | } 45 | 46 | 47 | def bonus_coefficient(category: Mapping[int, int], count: int) -> Decimal: 48 | """Get multiplication coefficient for cashback.""" 49 | assigned_level = 0 50 | for level, percent in category.items(): 51 | if level <= count and level > assigned_level: 52 | assigned_level = level 53 | return Decimal(category[assigned_level]) / 100 if assigned_level > 0 else Decimal(0) 54 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | Thank you for considering contributing to this project! The following are guidelines you need to follow. 3 | 4 | ## Code style 5 | To make sure your code style is consistent with project's code style, use [pre-commit](https://pre-commit.com/) which will automatically run formatters and linting tools before any commit: 6 | ```bash 7 | pip install -r requirements-dev.txt 8 | pre-commit install 9 | git commit 10 | ``` 11 | If any staged file is reformatted, you need to stage it again. If linting errors are found, you need to fix them before staging again. 12 | 13 | ## GPG commit signature verification 14 | To ensure your work comes from a trusted source, you are required to sign your commits with a GPG key that you generate yourself. You can read [this article from GitHub](https://help.github.com/articles/signing-commits/) as a guide. 15 | 16 | **Commits that do not have a cryptographically verifiable signature will not be accepted.** 17 | 18 | ## Contributor License Agreement 19 | In order to give project's owner permission to use and redistribute your contributions as part of the project, you must accept the [Contributor License Agreement](https://github.com/fincubator/tellerbot/blob/master/CLA.md) (CLA): 20 | 21 | To accept the CLA, contributors should: 22 | 23 | - declare the agreement with its terms in the comment to the pull request 24 | - have a line like this with contributor's name and e-mail address in the end of every commit message: 25 | ```Signed-off-by: John Smith ``` 26 | 27 | The latter can be done with a ```--signoff``` option either to ```git commit``` if you are signing-off a single commit or to ```git rebase``` if you are signing-off all commits in a pull request. 28 | 29 | **Contributions without agreement with the terms of Contributor License Agreement will not be accepted.** 30 | -------------------------------------------------------------------------------- /src/escrow/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | from src.config import config 18 | from src.escrow.blockchain import StreamBlockchain 19 | 20 | 21 | if config.ESCROW_ENABLED: 22 | from src.escrow.blockchain.golos_blockchain import GolosBlockchain 23 | from src.escrow.blockchain.cyber_blockchain import CyberBlockchain 24 | 25 | SUPPORTED_BLOCKCHAINS = [GolosBlockchain(), CyberBlockchain()] 26 | else: 27 | SUPPORTED_BLOCKCHAINS = [] 28 | 29 | 30 | SUPPORTED_BANKS = ("Alfa-Bank", "Sberbank", "Tinkoff") 31 | 32 | 33 | def get_escrow_instance(asset: str): 34 | """Find blockchain instance which supports ``asset``.""" 35 | for bc in SUPPORTED_BLOCKCHAINS: 36 | if asset in bc.assets: 37 | return bc 38 | 39 | 40 | async def connect_to_blockchains(): 41 | """Run ``connect()`` method on every blockchain instance.""" 42 | for bc in SUPPORTED_BLOCKCHAINS: 43 | await bc.connect() 44 | if isinstance(bc, StreamBlockchain) and bc._queue: 45 | bc.start_streaming() 46 | 47 | 48 | async def close_blockchains(): 49 | """Run ``close()`` method on every blockchain instance.""" 50 | for bc in SUPPORTED_BLOCKCHAINS: 51 | await bc.close() 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Database data 2 | data/ 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # celery beat schedule file 89 | celerybeat-schedule 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | # Pyre type checker 116 | .pyre/ 117 | 118 | # IDEA folder 119 | .idea/ 120 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | # -- Path setup -------------------------------------------------------------- 7 | # If extensions (or modules to document with autodoc) are in another directory, 8 | # add these directories to sys.path here. If the directory is relative to the 9 | # documentation root, use os.path.abspath to make it absolute, like shown here. 10 | # 11 | import os 12 | import sys 13 | 14 | sys.path.insert(0, os.path.abspath("../")) 15 | 16 | 17 | # -- Project information ----------------------------------------------------- 18 | 19 | project = "TellerBot" 20 | copyright = "2019, alfred richardsn" 21 | author = "alfred richardsn" 22 | 23 | 24 | # -- General configuration --------------------------------------------------- 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be 27 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 28 | # ones. 29 | extensions = ["sphinx.ext.autodoc", "sphinx.ext.viewcode"] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ["_templates"] 33 | 34 | # List of patterns, relative to source directory, that match files and 35 | # directories to ignore when looking for source files. 36 | # This pattern also affects html_static_path and html_extra_path. 37 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 38 | 39 | 40 | # -- Options for HTML output ------------------------------------------------- 41 | 42 | # The theme to use for HTML and HTML Help pages. See the documentation for 43 | # a list of builtin themes. 44 | # 45 | html_theme = "sphinx_rtd_theme" 46 | 47 | # Add any paths that contain custom static files (such as style sheets) here, 48 | # relative to this directory. They are copied after the builtin static files, 49 | # so a file named "default.css" will overwrite the builtin "default.css". 50 | html_static_path = ["_static"] 51 | 52 | master_doc = "index" 53 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | from os import getenv 18 | 19 | 20 | DEFAULT_VALUES = { 21 | "SET_WEBHOOK": False, 22 | "INTERNAL_HOST": "127.0.0.1", 23 | "DATABASE_HOST": "127.0.0.1", 24 | "DATABASE_PORT": 27017, 25 | "DATABASE_NAME": "tellerbot", 26 | "ESCROW_ENABLED": False, 27 | } 28 | 29 | 30 | def get_typed_env(key): 31 | """Get an environment variable with inferred type.""" 32 | env = getenv(key) 33 | if env is None: 34 | return None 35 | elif env == "true": 36 | return True 37 | elif env == "false": 38 | return False 39 | try: 40 | return int(env) 41 | except ValueError: 42 | return env 43 | 44 | 45 | class Config: 46 | """Lazy interface to configuration values.""" 47 | 48 | def __setattr__(self, name, value): 49 | """Set configuration value.""" 50 | super().__setattr__(name, value) 51 | 52 | def __getattr__(self, name): 53 | """Get configuration value. 54 | 55 | Return value of environment variable ``name`` if it is set or 56 | default value otherwise. 57 | """ 58 | env = get_typed_env(name) 59 | if env is not None: 60 | value = env 61 | elif name not in DEFAULT_VALUES: 62 | raise AttributeError(f"config has no option '{name}'") 63 | else: 64 | value = DEFAULT_VALUES[name] 65 | setattr(self, name, value) 66 | return value 67 | 68 | 69 | config = Config() 70 | -------------------------------------------------------------------------------- /src/money.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import decimal 18 | import re 19 | from decimal import Decimal 20 | 21 | from src.i18n import i18n 22 | 23 | HIGH_EXP = Decimal("1e15") 24 | LOW_EXP = Decimal("1e-8") 25 | 26 | 27 | def gateway_currency_regexp(currency): 28 | """Return regexp that ignores gateway if it isn't specified.""" 29 | return currency if "." in currency else re.compile(fr"^(\w+\.)?{currency}$") 30 | 31 | 32 | def normalize(money: Decimal, exp: Decimal = LOW_EXP) -> Decimal: 33 | """Round ``money`` to ``exp`` and strip trailing zeroes.""" 34 | if money == money.to_integral_value(): 35 | return money.quantize(Decimal(1)) 36 | return money.quantize(exp, rounding=decimal.ROUND_HALF_UP).normalize() 37 | 38 | 39 | def money(value) -> Decimal: 40 | """Try to return normalized money object constructed from ``value``.""" 41 | try: 42 | money = Decimal(value) 43 | except decimal.InvalidOperation: 44 | raise MoneyValueError(i18n("send_decimal_number")) 45 | if money <= 0: 46 | raise MoneyValueError(i18n("send_positive_number")) 47 | if money >= HIGH_EXP: 48 | raise MoneyValueError( 49 | i18n("exceeded_money_limit {limit}").format(limit=f"{HIGH_EXP:,f}") 50 | ) 51 | 52 | normalized = normalize(money) 53 | if normalized.is_zero(): 54 | raise MoneyValueError( 55 | i18n("shortage_money_limit {limit}").format(limit=f"{LOW_EXP:.8f}") 56 | ) 57 | return normalized 58 | 59 | 60 | class MoneyValueError(Exception): 61 | """Inappropriate money argument value.""" 62 | -------------------------------------------------------------------------------- /src/i18n.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019, 2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import gettext 18 | import typing 19 | from pathlib import Path 20 | 21 | from aiogram import types 22 | from aiogram.contrib.middlewares.i18n import I18nMiddleware 23 | 24 | from src.database import database_user 25 | 26 | 27 | class I18nMiddlewareManual(I18nMiddleware): 28 | """I18n middleware which gets user locale from database.""" 29 | 30 | def __init__(self, domain, path, default="en"): 31 | """Initialize I18nMiddleware without finding locales.""" 32 | super(I18nMiddleware, self).__init__() 33 | 34 | self.domain = domain 35 | self.path = path 36 | self.default = default 37 | 38 | def find_locales(self) -> typing.Dict[str, gettext.NullTranslations]: 39 | """Load all compiled locales from path and add default fallbacks.""" 40 | translations = super().find_locales() 41 | for translation in translations.values(): 42 | translation.add_fallback(translations[self.default]) 43 | return translations 44 | 45 | async def get_user_locale( 46 | self, action: str, args: typing.Tuple[typing.Any] 47 | ) -> typing.Optional[str]: 48 | """Get user locale by querying collection of users in database. 49 | 50 | Return value of ``locale`` field in user's corresponding 51 | document if it exists, otherwise return user's Telegram 52 | language if possible. 53 | """ 54 | if action not in ("pre_process_message", "pre_process_callback_query"): 55 | return None 56 | 57 | user: types.User = types.User.get_current() 58 | document = database_user.get() 59 | if document: 60 | locale = document.get("locale", user.language_code) 61 | else: 62 | locale = user.language_code 63 | return locale if locale in self.available_locales else self.default 64 | 65 | 66 | i18n = plural_i18n = I18nMiddlewareManual("bot", Path(__file__).parents[1] / "locale") 67 | -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019-2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import asyncio 18 | import secrets 19 | 20 | from aiogram.utils import executor 21 | 22 | from src import bot 23 | from src import handlers # noqa: F401 24 | from src import notifications 25 | from src.bot import dp 26 | from src.bot import tg 27 | from src.config import config 28 | from src.database import database 29 | from src.escrow import close_blockchains 30 | from src.escrow import connect_to_blockchains 31 | 32 | 33 | async def on_startup(webhook_path=None, *args): 34 | """Prepare bot before starting. 35 | 36 | Set webhook and run background tasks. 37 | """ 38 | await tg.delete_webhook() 39 | if webhook_path is not None: 40 | await tg.set_webhook("https://" + config.SERVER_HOST + webhook_path) 41 | await database.users.create_index("referral_code", unique=True, sparse=True) 42 | asyncio.create_task(notifications.run_loop()) 43 | asyncio.create_task(connect_to_blockchains()) 44 | 45 | 46 | def main(): 47 | """Start bot in webhook mode. 48 | 49 | Bot's main entry point. 50 | """ 51 | bot.setup() 52 | if config.SET_WEBHOOK: 53 | url_token = secrets.token_urlsafe() 54 | webhook_path = config.WEBHOOK_PATH + "/" + url_token 55 | 56 | executor.start_webhook( 57 | dispatcher=dp, 58 | webhook_path=webhook_path, 59 | on_startup=lambda *args: on_startup(webhook_path, *args), 60 | on_shutdown=lambda *args: close_blockchains(), 61 | host=config.INTERNAL_HOST, 62 | port=config.SERVER_PORT, 63 | ) 64 | else: 65 | executor.start_polling( 66 | dispatcher=dp, 67 | on_startup=lambda *args: on_startup(None, *args), 68 | on_shutdown=lambda *args: close_blockchains(), 69 | ) 70 | print() # noqa: T001 Executor stopped with ^C 71 | 72 | # Stop all background tasks 73 | loop = asyncio.get_event_loop() 74 | for task in asyncio.all_tasks(loop): 75 | task.cancel() 76 | try: 77 | loop.run_until_complete(task) 78 | except asyncio.CancelledError: 79 | pass 80 | -------------------------------------------------------------------------------- /src/whitelist.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019, 2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | """Whitelists for orders.""" 18 | from typing import Mapping 19 | from typing import Tuple 20 | 21 | from aiogram.types import KeyboardButton 22 | from aiogram.types import ReplyKeyboardMarkup 23 | from aiogram.utils.emoji import emojize 24 | 25 | from src.i18n import i18n 26 | 27 | FIAT: Tuple[str, ...] = ("CNY", "EUR", "RUB", "UAH", "USD") 28 | 29 | CRYPTOCURRENCY: Mapping[str, Tuple[str, ...]] = { 30 | "BTC": ("GDEX", "RUDEX"), 31 | "BTS": (), 32 | "CYBER": (), 33 | "EOS": ("GDEX", "RUDEX"), 34 | "ETH": ("GDEX", "RUDEX"), 35 | "GOLOS": ("CYBER",), 36 | "HIVE": (), 37 | "STEEM": ("GDEX", "RUDEX"), 38 | "TRON": (), 39 | "USDT": ("BTC", "EOS", "ETH", "FINTEH", "GDEX", "RUDEX", "TRON"), 40 | "VIZ": (), 41 | "BIP": (), 42 | "CMN": (), 43 | } 44 | 45 | 46 | def currency_keyboard(currency_type: str) -> ReplyKeyboardMarkup: 47 | """Get keyboard with currencies from whitelists.""" 48 | keyboard = ReplyKeyboardMarkup( 49 | row_width=5, one_time_keyboard=currency_type == "sell" 50 | ) 51 | keyboard.row(*[KeyboardButton(c) for c in FIAT]) 52 | keyboard.add(*[KeyboardButton(c) for c in CRYPTOCURRENCY]) 53 | cancel_button = KeyboardButton(emojize(":x: ") + i18n("cancel")) 54 | if currency_type == "sell": 55 | keyboard.row( 56 | KeyboardButton(emojize(":fast_reverse_button: ") + i18n("back")), 57 | cancel_button, 58 | ) 59 | else: 60 | keyboard.row(cancel_button) 61 | return keyboard 62 | 63 | 64 | def gateway_keyboard(currency: str, currency_type: str) -> ReplyKeyboardMarkup: 65 | """Get keyboard with gateways of ``currency`` from whitelist.""" 66 | keyboard = ReplyKeyboardMarkup( 67 | row_width=5, one_time_keyboard=currency_type == "sell" 68 | ) 69 | keyboard.add(*[KeyboardButton(g) for g in CRYPTOCURRENCY[currency]]) 70 | keyboard.row( 71 | KeyboardButton(emojize(":fast_reverse_button: ") + i18n("back")), 72 | KeyboardButton(emojize(":fast_forward: ") + i18n("without_gateway")), 73 | KeyboardButton(emojize(":x: ") + i18n("cancel")), 74 | ) 75 | return keyboard 76 | -------------------------------------------------------------------------------- /src/notifications.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import asyncio 18 | import typing 19 | from time import time 20 | 21 | from aiogram.utils.exceptions import TelegramAPIError 22 | 23 | from src.bot import tg 24 | from src.database import database 25 | from src.handlers.base import show_order 26 | from src.i18n import i18n 27 | from src.money import gateway_currency_regexp 28 | 29 | 30 | async def run_loop(): 31 | """Notify order creators about expired orders in infinite loop.""" 32 | while True: 33 | cursor = database.orders.find( 34 | {"expiration_time": {"$lte": time()}, "notify": True} 35 | ) 36 | sent = False 37 | async for order in cursor: 38 | user = await database.users.find_one({"id": order["user_id"]}) 39 | message = i18n("order_expired", locale=user["locale"]) 40 | message += "\nID: {}".format(order["_id"]) 41 | try: 42 | if sent: 43 | await asyncio.sleep(1) # Avoid Telegram limit 44 | await tg.send_message(user["chat"], message) 45 | except TelegramAPIError: 46 | pass 47 | else: 48 | await show_order(order, user["chat"], user["id"], locale=user["locale"]) 49 | sent = True 50 | finally: 51 | await database.orders.update_one( 52 | {"_id": order["_id"]}, {"$set": {"notify": False}} 53 | ) 54 | if not sent: 55 | await asyncio.sleep(1) # Reduce database load 56 | 57 | 58 | async def order_notification(order: typing.Mapping[str, typing.Any]): 59 | """Notify users about order. 60 | 61 | Subscriptions to these notifications are managed with 62 | **/subscribe** or **/unsubscribe** commands of ``start_menu`` 63 | handlers. 64 | """ 65 | users = database.subscriptions.find( 66 | { 67 | "subscriptions": { 68 | "$elemMatch": { 69 | "buy": {"$in": [gateway_currency_regexp(order["buy"]), None]}, 70 | "sell": {"$in": [gateway_currency_regexp(order["sell"]), None]}, 71 | } 72 | }, 73 | } 74 | ) 75 | async for user in users: 76 | if user["id"] == order["user_id"]: 77 | continue 78 | order = await database.orders.find_one({"_id": order["_id"]}) # Update order 79 | if not order or order.get("archived"): 80 | return 81 | await show_order( 82 | order, user["chat"], user["id"], show_id=True, locale=user["locale"] 83 | ) 84 | await asyncio.sleep(1) # Avoid Telegram limit 85 | -------------------------------------------------------------------------------- /src/states.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | from aiogram.dispatcher.filters.state import State 18 | from aiogram.dispatcher.filters.state import StatesGroup 19 | 20 | 21 | class OrderCreation(StatesGroup): 22 | """Steps of order creation. 23 | 24 | States represent values user is required to send. They are in order 25 | and skippable unless otherwise specified. 26 | """ 27 | 28 | #: Currency user wants to buy (unskippable). 29 | buy = State() 30 | #: Gateway of buy currency (unskippable). 31 | buy_gateway = State() 32 | #: Currency user wants to sell (unskippable). 33 | sell = State() 34 | #: Gateway of sell currency (unskippable). 35 | sell_gateway = State() 36 | #: Price in one of the currencies. 37 | price = State() 38 | #: Sum in any of the currencies. 39 | amount = State() 40 | #: Cashless payment system. 41 | payment_system = State() 42 | #: Location object or location name. 43 | location = State() 44 | #: Duration in days. 45 | duration = State() 46 | #: Any additional comments. 47 | comments = State() 48 | #: Finish order creation by skipping comments state. 49 | set_order = State() 50 | 51 | 52 | class Escrow(StatesGroup): 53 | """States of user during escrow exchange. 54 | 55 | States are uncontrollable by users and are only used to determine 56 | what action user is required to perform. Because there are two 57 | parties in escrow exchange and steps are dependant on which 58 | currencies are used, states do not define full steps of exchange. 59 | """ 60 | 61 | #: Send sum in any of the currencies. 62 | amount = State() 63 | #: Agree or disagree to pay fee. 64 | fee = State() 65 | #: Choose escrow initiator's bank from listed. 66 | bank = State() 67 | #: Send fiat sender's name on card. 68 | #: Required to verify fiat transfer. 69 | name = State() 70 | #: Send fiat receiver's full card number to fiat sender. 71 | full_card = State() 72 | #: Send escrow asset receiver's address in blockchain. 73 | receive_address = State() 74 | #: Send first and last 4 digits of fiat receiver's card number. 75 | receive_card_number = State() 76 | #: Escrow asset sender's address in blockchain. 77 | send_address = State() 78 | #: Send first and last 4 digits of fiat sender's card number. 79 | send_card_number = State() 80 | 81 | 82 | #: Ask support a question. 83 | asking_support = State("asking_support") 84 | #: Send new value of chosen order's field during editing. 85 | field_editing = State("field_editing") 86 | #: Send new value of chosen order's field during editing. 87 | cashback_address = State("cashback_address") 88 | -------------------------------------------------------------------------------- /src/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019, 2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | """Handler modules with default and fallback handlers.""" 18 | import logging 19 | import traceback 20 | 21 | from aiogram import types 22 | from aiogram.dispatcher.filters.state import any_state 23 | from aiogram.utils import markdown 24 | from aiogram.utils.exceptions import MessageNotModified 25 | 26 | from src.bot import dp 27 | from src.bot import tg 28 | from src.config import config 29 | from src.handlers import start_menu # noqa: F401, noreorder 30 | from src.handlers import creation # noqa: F401 31 | from src.handlers import escrow # noqa: F401 32 | from src.handlers import cashback # noqa: F401 33 | from src.handlers import order # noqa: F401 34 | from src.handlers import support # noqa: F401 35 | from src.handlers.base import private_handler 36 | from src.handlers.base import start_keyboard 37 | from src.i18n import i18n 38 | 39 | log = logging.getLogger(__name__) 40 | 41 | 42 | @private_handler(state=any_state) 43 | async def default_message(message: types.Message): 44 | """React to message which has not passed any previous conditions.""" 45 | await tg.send_message( 46 | message.chat.id, i18n("unknown_command"), reply_markup=start_keyboard() 47 | ) 48 | 49 | 50 | @dp.callback_query_handler(state=any_state) 51 | async def default_callback_query(call: types.CallbackQuery): 52 | """React to query which has not passed any previous conditions. 53 | 54 | If callback query is not answered, button will stuck in loading as 55 | if the bot stopped working until it times out. So unknown buttons 56 | are better be answered accordingly. 57 | """ 58 | await call.answer(i18n("unknown_button")) 59 | 60 | 61 | @dp.errors_handler() 62 | async def errors_handler(update: types.Update, exception: Exception): 63 | """Handle exceptions when calling handlers. 64 | 65 | Send error notification to special chat and warn user about the error. 66 | """ 67 | if isinstance(exception, MessageNotModified): 68 | return True 69 | 70 | log.error("Error handling request {}".format(update.update_id), exc_info=True) 71 | 72 | chat_id = None 73 | if update.message: 74 | update_type = "message" 75 | from_user = update.message.from_user 76 | chat_id = update.message.chat.id 77 | if update.callback_query: 78 | update_type = "callback query" 79 | from_user = update.callback_query.from_user 80 | chat_id = update.callback_query.message.chat.id 81 | 82 | if chat_id is not None: 83 | try: 84 | exceptions_chat_id = config.EXCEPTIONS_CHAT_ID 85 | except AttributeError: 86 | pass 87 | else: 88 | await tg.send_message( 89 | exceptions_chat_id, 90 | "Error handling {} {} from {} ({}) in chat {}\n{}".format( 91 | update_type, 92 | update.update_id, 93 | markdown.link(from_user.mention, from_user.url), 94 | from_user.id, 95 | chat_id, 96 | markdown.code(traceback.format_exc(limit=-3)), 97 | ), 98 | parse_mode=types.ParseMode.MARKDOWN, 99 | ) 100 | await tg.send_message( 101 | chat_id, 102 | i18n("unexpected_error"), 103 | reply_markup=start_keyboard(), 104 | ) 105 | 106 | return True 107 | -------------------------------------------------------------------------------- /src/database.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import typing 18 | from contextvars import ContextVar 19 | 20 | from aiogram.dispatcher.storage import BaseStorage 21 | from motor.motor_asyncio import AsyncIOMotorClient 22 | 23 | from src.config import config 24 | 25 | 26 | try: 27 | with open(config.DATABASE_PASSWORD_FILENAME, "r") as password_file: 28 | client = AsyncIOMotorClient( 29 | "mongodb://{username}:{password}@{host}:{port}/{name}".format( 30 | host=config.DATABASE_HOST, 31 | port=config.DATABASE_PORT, 32 | username=config.DATABASE_USERNAME, 33 | password=password_file.read().strip(), 34 | name=config.DATABASE_NAME, 35 | ) 36 | ) 37 | except (AttributeError, FileNotFoundError): 38 | client = AsyncIOMotorClient(config.DATABASE_HOST) 39 | database = client[config.DATABASE_NAME] 40 | 41 | database_user: ContextVar[typing.Mapping[str, typing.Any]] = ContextVar("database_user") 42 | 43 | 44 | class MongoStorage(BaseStorage): 45 | """MongoDB asynchronous storage for FSM using motor.""" 46 | 47 | async def get_state(self, user: int, **kwargs) -> typing.Optional[str]: 48 | """Get current state of user with Telegram ID ``user``.""" 49 | document = await database.users.find_one({"id": user}) 50 | return document.get("state") if document else None 51 | 52 | async def set_state( 53 | self, user: int, state: typing.Optional[str] = None, **kwargs 54 | ) -> None: 55 | """Set new state ``state`` of user with Telegram ID ``user``.""" 56 | if state is None: 57 | await database.users.update_one({"id": user}, {"$unset": {"state": True}}) 58 | else: 59 | await database.users.update_one({"id": user}, {"$set": {"state": state}}) 60 | 61 | async def get_data(self, user: int, **kwargs) -> typing.Dict: 62 | """Get state data of user with Telegram ID ``user``.""" 63 | document = await database.users.find_one({"id": user}) 64 | return document.get("data", {}) 65 | 66 | async def set_data( 67 | self, user: int, data: typing.Optional[typing.Dict] = None, **kwargs 68 | ) -> None: 69 | """Set state data ``data`` of user with Telegram ID ``user``.""" 70 | if data is None: 71 | await database.users.update_one({"id": user}, {"$unset": {"data": True}}) 72 | else: 73 | await database.users.update_one({"id": user}, {"$set": {"data": data}}) 74 | 75 | async def update_data( 76 | self, user: int, data: typing.Optional[typing.Dict] = None, **kwargs 77 | ) -> None: 78 | """Update data of user with Telegram ID ``user``.""" 79 | if data is None: 80 | data = {} 81 | data.update(kwargs) 82 | await database.users.update_one( 83 | {"id": user}, 84 | {"$set": {f"data.{key}": value for key, value in data.items()}}, 85 | ) 86 | 87 | async def reset_state(self, user: int, with_data: bool = True, **kwargs): 88 | """Reset state for user with Telegram ID ``user``.""" 89 | update = {"$unset": {"state": True}} 90 | if with_data: 91 | update["$unset"]["data"] = True 92 | await database.users.update_one({"id": user}, update) 93 | 94 | async def finish(self, user: int, **kwargs): 95 | """Finish conversation with user.""" 96 | await self.set_state(user=user, state=None) 97 | 98 | async def wait_closed(self) -> None: 99 | """Do nothing. 100 | 101 | Motor client does not use this method. 102 | """ 103 | 104 | async def close(self): 105 | """Disconnect from MongoDB.""" 106 | client.close() 107 | -------------------------------------------------------------------------------- /src/escrow/escrow_offer.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import typing 18 | from dataclasses import dataclass 19 | 20 | from bson.decimal128 import Decimal128 21 | from bson.objectid import ObjectId 22 | 23 | from src.database import database 24 | 25 | 26 | def asdict(instance): 27 | """Represent class instance as dictionary excluding None values.""" 28 | return {key: value for key, value in instance.__dict__.items() if value is not None} 29 | 30 | 31 | @dataclass 32 | class EscrowOffer: 33 | """Class used to represent escrow offer. 34 | 35 | Attributes correspond to fields in database document. 36 | """ 37 | 38 | #: Primary key value of offer document. 39 | _id: ObjectId 40 | #: Primary key value of corresponding order document. 41 | order: ObjectId 42 | #: Currency which order creator wants to buy. 43 | buy: str 44 | #: Currency which order creator wants to sell. 45 | sell: str 46 | #: Type of offer. Field of currency which is held during exchange. 47 | type: str # noqa: A003 48 | #: Currency which is held during exchange. 49 | escrow: str 50 | #: Unix time stamp of offer creation. 51 | time: float 52 | #: Object representing initiator of escrow. 53 | init: typing.Mapping[str, typing.Any] 54 | #: Object representing counteragent of escrow. 55 | counter: typing.Mapping[str, typing.Any] 56 | #: Telegram ID of user required to send message to bot. 57 | pending_input_from: typing.Optional[int] = None 58 | #: Temporary field of currency in which user is sending amount. 59 | sum_currency: typing.Optional[str] = None 60 | #: Amount in ``buy`` currency. 61 | sum_buy: typing.Optional[Decimal128] = None 62 | #: Amount in ``sell`` currency. 63 | sum_sell: typing.Optional[Decimal128] = None 64 | #: Amount of held currency with agreed fee added. 65 | sum_fee_up: typing.Optional[Decimal128] = None 66 | #: Amount of held currency with agreed fee substracted. 67 | sum_fee_down: typing.Optional[Decimal128] = None 68 | #: Amount of insured currency. 69 | insured: typing.Optional[Decimal128] = None 70 | #: Unix time stamp of counteragent first reaction to sent offer. 71 | react_time: typing.Optional[float] = None 72 | #: Unix time stamp since which transaction should be checked. 73 | transaction_time: typing.Optional[float] = None 74 | #: Unix time stamp of offer cancellation. 75 | cancel_time: typing.Optional[float] = None 76 | #: Bank of fiat currency. 77 | bank: typing.Optional[str] = None 78 | #: Required memo in blockchain transaction. 79 | memo: typing.Optional[str] = None 80 | #: ID of verified transaction. 81 | trx_id: typing.Optional[str] = None 82 | #: True if non-escrow token sender hasn't confirmed their transfer. 83 | unsent: typing.Optional[bool] = None 84 | 85 | def __getitem__(self, key: str) -> typing.Any: 86 | """Allow to use class as dictionary.""" 87 | return asdict(self)[key] 88 | 89 | async def insert_document(self) -> None: 90 | """Convert self to document and insert to database.""" 91 | await database.escrow.insert_one(asdict(self)) 92 | 93 | async def update_document(self, update) -> None: 94 | """Update corresponding document in database. 95 | 96 | :param update: Document with update operators or aggregation 97 | pipeline sent to MongoDB. 98 | """ 99 | await database.escrow.update_one({"_id": self._id}, update) 100 | 101 | async def delete_document(self) -> None: 102 | """Archive and delete corresponding document in database.""" 103 | await database.escrow_archive.insert_one(asdict(self)) 104 | await database.escrow.delete_one({"_id": self._id}) 105 | -------------------------------------------------------------------------------- /src/handlers/support.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | """Handlers for interacting with support.""" 18 | from aiogram import types 19 | from aiogram.dispatcher import FSMContext 20 | from aiogram.utils import markdown 21 | from aiogram.utils.emoji import emojize 22 | from aiogram.utils.exceptions import BotBlocked 23 | 24 | from src.bot import dp 25 | from src.config import config 26 | from src.handlers.base import private_handler 27 | from src.handlers.base import start_keyboard 28 | from src.handlers.base import tg 29 | from src.i18n import i18n 30 | from src.states import asking_support 31 | 32 | 33 | @dp.callback_query_handler( 34 | lambda call: call.data.startswith("unhelp"), state=asking_support 35 | ) 36 | async def unhelp_button(call: types.CallbackQuery, state: FSMContext): 37 | """Cancel request to support.""" 38 | await state.finish() 39 | await call.answer() 40 | await tg.send_message( 41 | call.message.chat.id, 42 | i18n("request_cancelled"), 43 | reply_markup=start_keyboard(), 44 | ) 45 | 46 | 47 | async def send_message_to_support(message: types.Message): 48 | """Format message and send it to support. 49 | 50 | Envelope emoji at the beginning is the mark of support ticket. 51 | """ 52 | if message.from_user.username: 53 | username = "@" + message.from_user.username 54 | else: 55 | username = markdown.link(message.from_user.full_name, message.from_user.url) 56 | 57 | await tg.send_message( 58 | config.SUPPORT_CHAT_ID, 59 | emojize(":envelope:") 60 | + f" #chat\\_{message.chat.id} {message.message_id}\n{username}:\n" 61 | + markdown.escape_md(message.text), 62 | parse_mode=types.ParseMode.MARKDOWN, 63 | ) 64 | await tg.send_message( 65 | message.chat.id, 66 | i18n("support_response_promise"), 67 | reply_markup=start_keyboard(), 68 | ) 69 | 70 | 71 | @private_handler(state=asking_support) 72 | async def contact_support(message: types.Message, state: FSMContext): 73 | """Send message to support after request in start manu.""" 74 | await send_message_to_support(message) 75 | await state.finish() 76 | 77 | 78 | @private_handler( 79 | lambda msg: msg.reply_to_message is not None 80 | and msg.reply_to_message.text.startswith(emojize(":speech_balloon:")) 81 | ) 82 | async def handle_reply(message: types.Message): 83 | """Answer support's reply to ticket.""" 84 | me = await tg.me 85 | if message.reply_to_message.from_user.id == me.id: 86 | await send_message_to_support(message) 87 | 88 | 89 | @dp.message_handler( 90 | lambda msg: msg.chat.id == config.SUPPORT_CHAT_ID 91 | and msg.reply_to_message is not None 92 | and msg.reply_to_message.text.startswith(emojize(":envelope: ")) 93 | ) 94 | async def answer_support_ticket(message: types.Message): 95 | """Answer support ticket. 96 | 97 | Speech balloon emoji at the beginning is the mark of support's 98 | reply to ticket. 99 | """ 100 | me = await tg.me 101 | if message.reply_to_message.from_user.id == me.id: 102 | args = message.reply_to_message.text.splitlines()[0].split() 103 | chat_id = int(args[1].split("_")[1]) 104 | reply_to_message_id = int(args[2]) 105 | 106 | try: 107 | await tg.send_message( 108 | chat_id, 109 | emojize(":speech_balloon:") + message.text, 110 | reply_to_message_id=reply_to_message_id, 111 | ) 112 | except BotBlocked: 113 | await tg.send_message(message.chat.id, i18n("reply_error_bot_blocked")) 114 | else: 115 | await tg.send_message(message.chat.id, i18n("reply_sent")) 116 | 117 | 118 | @dp.message_handler( 119 | lambda msg: msg.chat.id == config.SUPPORT_CHAT_ID, commands=["toggle_escrow"] 120 | ) 121 | async def toggle_escrow(message: types.Message): 122 | """Toggle escrow availability. 123 | 124 | This command makes creation of new escrow offers unavailable if 125 | escrow is enabled, and makes it available if it's disabled. 126 | """ 127 | config.ESCROW_ENABLED = not config.ESCROW_ENABLED 128 | if config.ESCROW_ENABLED: 129 | await tg.send_message(message.chat.id, i18n("escrow_enabled")) 130 | else: 131 | await tg.send_message(message.chat.id, i18n("escrow_disabled")) 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

TellerBot

3 | 4 | 5 | [![Documentation Status](https://readthedocs.org/projects/tellerbot/badge/?version=latest)](https://tellerbot.readthedocs.io/en/latest/?badge=latest) 6 | [![pre-commit](https://github.com/fincubator/tellerbot/workflows/pre-commit/badge.svg)](https://github.com/fincubator/tellerbot/actions?query=workflow%3Apre-commit) 7 | [![Translation Status](https://hosted.weblate.org/widgets/tellerbot/-/tellerbot/svg-badge.svg)](https://hosted.weblate.org/engage/tellerbot/?utm_source=widget) 8 | [![GitHub license](https://img.shields.io/github/license/fincubator/tellerbot)](https://github.com/PreICO/tellerbot/blob/escrow/COPYING) 9 | [![Telegram](https://img.shields.io/badge/Telegram-tellerchat-blue?logo=telegram)](https://t.me/tellerchat) 10 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 11 |
12 | 13 | --- 14 | 15 | 16 | [@TellerBot](https://t.me/TellerBot) is an asynchronous Telegram Bot written in Python to help you meet people that you can swap money with. 17 | 18 | 19 | ## Requirements 20 | * [Python](https://www.python.org/downloads) >= 3.8 21 | * [MongoDB](https://docs.mongodb.com/manual/installation/) 22 | * [Motor](https://github.com/mongodb/motor) - asynchronous Python driver for MongoDB 23 | * [AIOgram](https://github.com/aiogram/aiogram) - asynchronous Python library for Telegram Bot API 24 | * [Emoji](https://github.com/carpedm20/emoji) - emoji for Python 25 | 26 | 27 | ## Installation and launch 28 | ### Using Docker (recommended) 29 | 1. Clone the repository: 30 | ```bash 31 | git clone https://github.com/fincubator/tellerbot 32 | cd tellerbot 33 | ``` 34 | 2. Create environment file from example: 35 | ```bash 36 | cp .env.example .env 37 | ``` 38 | 3. Personalize settings by modifying ```.env``` with your preferable text editor. 39 | 4. Create a new Telegram bot by talking to [@BotFather](https://t.me/BotFather) and get its API token. 40 | 5. Create a file containing Telegram bot's API token with filename specified in ```TOKEN_FILENAME``` from ```.env``` (example in [secrets/tbtoken](secrets/tbtoken)). 41 | 6. *(Optional)* If you're going to support escrow, set ```ESCROW_ENABLED=true``` in ```.env``` and create a file containing JSON mapping blockchain names to bot's WIF and API nodes with filename specified in ```ESCROW_FILENAME``` from ```.env``` (example in [secrets/escrow.json](secrets/escrow.json)). 42 | 7. Create a file containing database password with filename specified in ```DATABASE_PASSWORD_FILENAME``` from ```.env``` (example in [secrets/dbpassword](secrets/dbpassword)). 43 | 8. Install [Docker Compose](https://docs.docker.com/compose/install/) version no less than 1.26.0. 44 | 9. Start container: 45 | ```bash 46 | docker-compose up --build 47 | ``` 48 | 49 | For subsequent launches starting container is enough. 50 | 51 | ### Manual 52 | 1. Clone the repository: 53 | ```bash 54 | git clone https://github.com/fincubator/tellerbot 55 | cd tellerbot 56 | ``` 57 | 2. Install Python version no less than 3.8 with [pip](https://pip.pypa.io/en/stable/installing/). 58 | 3. Install requirements: 59 | ```bash 60 | pip install -r requirements.txt 61 | pip install -r requirements-escrow.txt # If you're going to support escrow 62 | ``` 63 | 4. Compile translations: 64 | ```bash 65 | pybabel compile -d locale/ -D bot 66 | ``` 67 | 5. Create environment file from example: 68 | ```bash 69 | cp .env.example .env 70 | ``` 71 | 6. Personalize settings by modifying ```.env``` with your preferable text editor. Remove ```INTERNAL_HOST``` and ```DATABASE_HOST``` if you want bot and database running on localhost. 72 | 7. Create a new Telegram bot by talking to [@BotFather](https://t.me/BotFather) and get its API token. 73 | 8. Create a file containing Telegram bot's API token with filename specified in ```TOKEN_FILENAME``` from ```.env``` (example in [secrets/tbtoken](secrets/tbtoken)). 74 | 9. *(Optional)* If you're going to support escrow, set ```ESCROW_ENABLED=true``` in ```.env``` and create a file containing JSON mapping blockchain names to bot's WIF and API nodes with filename specified in ```ESCROW_FILENAME``` from ```.env``` (example in [secrets/escrow.json](secrets/escrow.json)). 75 | 10. Create a file containing database password with filename specified in ```DATABASE_PASSWORD_FILENAME``` from ```.env``` (example in [secrets/dbpassword](secrets/dbpassword)). 76 | 11. Install and start [MongoDB server](https://docs.mongodb.com/manual/installation/). 77 | 12. Set environment variables: 78 | ```bash 79 | export $(sed 's/#.*//' .env | xargs) 80 | ``` 81 | 13. Create database user: 82 | ```bash 83 | ./mongo-init.sh 84 | ``` 85 | 14. Restart MongoDB server with [access control enabled](https://docs.mongodb.com/manual/tutorial/enable-authentication/#re-start-the-mongodb-instance-with-access-control). 86 | 15. Launch TellerBot: 87 | ```bash 88 | python . 89 | ``` 90 | 91 | For subsequent launches setting enviroment variables and launching TellerBot is enough. 92 | 93 | ## Contributing 94 | You can help by working on [opened issues](https://github.com/fincubator/tellerbot/issues), fixing bugs, creating new features, improving documentation or [translating bot messages to your language](https://hosted.weblate.org/engage/tellerbot/). 95 | 96 | Before contributing, please read [CONTRIBUTING.md](CONTRIBUTING.md) first. 97 | 98 | 99 | ## License 100 | TellerBot is released under the GNU Affero General Public License v3.0. See [COPYING](COPYING) for the full licensing conditions. 101 | -------------------------------------------------------------------------------- /src/bot.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import asyncio 18 | import logging 19 | import typing 20 | from time import time 21 | 22 | from aiogram import Bot 23 | from aiogram import types 24 | from aiogram.bot import api 25 | from aiogram.contrib.middlewares.logging import LoggingMiddleware 26 | from aiogram.dispatcher import Dispatcher 27 | from aiogram.dispatcher.middlewares import BaseMiddleware 28 | from pymongo import ReturnDocument 29 | 30 | from src.config import config 31 | from src.database import database 32 | from src.database import database_user 33 | from src.database import MongoStorage 34 | from src.i18n import i18n 35 | 36 | 37 | class IncomingHistoryMiddleware(BaseMiddleware): 38 | """Middleware for storing incoming history.""" 39 | 40 | async def trigger(self, action, args): 41 | """Save incoming data in the database.""" 42 | if ( 43 | "update" not in action 44 | and "error" not in action 45 | and action.startswith("pre_process_") 46 | ): 47 | await database.logs.insert_one( 48 | { 49 | "direction": "in", 50 | "type": action.split("pre_process_", 1)[1], 51 | "data": args[0].to_python(), 52 | } 53 | ) 54 | 55 | 56 | class TellerBot(Bot): 57 | """Custom bot class.""" 58 | 59 | async def request(self, method, data=None, *args, **kwargs): 60 | """Make a request and save it in the database.""" 61 | result = await super().request(method, data, *args, **kwargs) 62 | if ( 63 | config.DATABASE_LOGGING_ENABLED 64 | and result 65 | and method 66 | not in ( 67 | api.Methods.GET_UPDATES, 68 | api.Methods.SET_WEBHOOK, 69 | api.Methods.DELETE_WEBHOOK, 70 | api.Methods.GET_WEBHOOK_INFO, 71 | api.Methods.GET_ME, 72 | ) 73 | ): 74 | # On requests Telegram either returns True on success or relevant object. 75 | # To store only useful information, method's payload is saved if result is 76 | # a boolean and result is saved otherwise. 77 | await database.logs.insert_one( 78 | { 79 | "direction": "out", 80 | "type": method, 81 | "data": data if isinstance(result, bool) else result, 82 | } 83 | ) 84 | return result 85 | 86 | 87 | class DispatcherManual(Dispatcher): 88 | """Dispatcher with user availability in database check.""" 89 | 90 | async def process_update(self, update: types.Update): 91 | """Process update object with user availability in database check. 92 | 93 | If bot doesn't know the user, it pretends they sent /start message. 94 | """ 95 | user = None 96 | if update.message: 97 | user = update.message.from_user 98 | chat = update.message.chat 99 | elif update.callback_query and update.callback_query.message: 100 | user = update.callback_query.from_user 101 | chat = update.callback_query.message.chat 102 | if user: 103 | await database.users.update_many( 104 | {"id": {"$ne": user.id}, "mention": user.mention}, 105 | {"$set": {"has_username": False}}, 106 | ) 107 | document = await database.users.find_one_and_update( 108 | {"id": user.id, "chat": chat.id}, 109 | { 110 | "$set": { 111 | "mention": user.mention, 112 | "has_username": bool(user.username), 113 | } 114 | }, 115 | return_document=ReturnDocument.AFTER, 116 | ) 117 | if document is None: 118 | if update.message: 119 | if not update.message.text.startswith("/start "): 120 | update.message.text = "/start" 121 | elif update.callback_query: 122 | await update.callback_query.answer() 123 | update = types.Update( 124 | update_id=update.update_id, 125 | message={ 126 | "message_id": -1, 127 | "from": user.to_python(), 128 | "chat": chat.to_python(), 129 | "date": int(time()), 130 | "text": "/start", 131 | }, 132 | ) 133 | database_user.set(document) 134 | return await super().process_update(update) 135 | 136 | 137 | tg = TellerBot(None, loop=asyncio.get_event_loop(), validate_token=False) 138 | dp = DispatcherManual(tg) 139 | 140 | 141 | def setup(): 142 | """Set API token from config to bot and setup dispatcher.""" 143 | with open(config.TOKEN_FILENAME, "r") as token_file: 144 | tg._ctx_token.set(token_file.read().strip()) 145 | 146 | dp.storage = MongoStorage() 147 | 148 | i18n.reload() 149 | dp.middleware.setup(i18n) 150 | 151 | logging.basicConfig(level=config.LOGGER_LEVEL) 152 | dp.middleware.setup(LoggingMiddleware()) 153 | if config.DATABASE_LOGGING_ENABLED: 154 | dp.middleware.setup(IncomingHistoryMiddleware()) 155 | 156 | 157 | def private_handler(*args, **kwargs): 158 | """Register handler only for private message.""" 159 | 160 | def decorator(handler: typing.Callable): 161 | dp.register_message_handler( 162 | handler, 163 | lambda message: message.chat.type == types.ChatType.PRIVATE, # noqa: E721 164 | *args, 165 | **kwargs 166 | ) 167 | return handler 168 | 169 | return decorator 170 | 171 | 172 | state_handlers = {} 173 | 174 | 175 | def state_handler(state): 176 | """Associate ``state`` with decorated handler.""" 177 | 178 | def decorator(handler): 179 | state_handlers[state.state] = handler 180 | return handler 181 | 182 | return decorator 183 | -------------------------------------------------------------------------------- /src/handlers/cashback.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019, 2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | """Handlers for cashback.""" 18 | import pymongo 19 | from aiogram import types 20 | from aiogram.dispatcher import FSMContext 21 | from aiogram.dispatcher.filters.state import any_state 22 | from aiogram.types import InlineKeyboardButton 23 | from aiogram.types import InlineKeyboardMarkup 24 | from aiogram.types import KeyboardButton 25 | from aiogram.types import ParseMode 26 | from aiogram.types import ReplyKeyboardMarkup 27 | from aiogram.utils import markdown 28 | 29 | from src import states 30 | from src.bot import dp 31 | from src.bot import tg 32 | from src.database import database 33 | from src.escrow import get_escrow_instance 34 | from src.escrow.blockchain import TransferError 35 | from src.handlers.base import private_handler 36 | from src.handlers.base import start_keyboard 37 | from src.i18n import i18n 38 | 39 | 40 | @dp.callback_query_handler( 41 | lambda call: call.data.startswith("claim_currency "), state=any_state 42 | ) 43 | async def claim_currency(call: types.CallbackQuery): 44 | """Set cashback currency and suggest last escrow address.""" 45 | currency = call.data.split()[1] 46 | cursor = ( 47 | database.cashback.find( 48 | {"id": call.from_user.id, "currency": currency, "address": {"$ne": None}} 49 | ) 50 | .sort("time", pymongo.DESCENDING) 51 | .limit(1) 52 | ) 53 | last_cashback = await cursor.to_list(length=1) 54 | if last_cashback: 55 | address = last_cashback[0]["address"] 56 | keyboard = InlineKeyboardMarkup(row_width=1) 57 | keyboard.add( 58 | InlineKeyboardButton( 59 | i18n("confirm_cashback_address"), 60 | callback_data=f"claim_transfer {currency} {address}", 61 | ), 62 | InlineKeyboardButton( 63 | i18n("custom_cashback_address"), 64 | callback_data=f"custom_cashback_address {currency}", 65 | ), 66 | ) 67 | await call.answer() 68 | await tg.edit_message_text( 69 | i18n("use_cashback_address {address}").format( 70 | address=markdown.code(address) 71 | ), 72 | call.message.chat.id, 73 | call.message.message_id, 74 | reply_markup=keyboard, 75 | parse_mode=ParseMode.MARKDOWN, 76 | ) 77 | else: 78 | return await custom_cashback_address(call) 79 | 80 | 81 | @dp.callback_query_handler( 82 | lambda call: call.data.startswith("custom_cashback_address "), state=any_state 83 | ) 84 | async def custom_cashback_address(call: types.CallbackQuery): 85 | """Ask for a custom cashback address.""" 86 | currency = call.data.split()[1] 87 | await states.cashback_address.set() 88 | await dp.current_state().update_data(currency=currency) 89 | answer = i18n("send_cashback_address") 90 | cursor = database.cashback.find( 91 | {"id": call.from_user.id, "currency": currency, "address": {"$ne": None}} 92 | ).sort("time", pymongo.DESCENDING) 93 | addresses = await cursor.distinct("address") 94 | addresses = addresses[1:] 95 | await call.answer() 96 | if addresses: 97 | keyboard = ReplyKeyboardMarkup(row_width=1) 98 | keyboard.add(*[KeyboardButton(address) for address in addresses]) 99 | await tg.send_message(call.message.chat.id, answer, reply_markup=keyboard) 100 | else: 101 | await tg.send_message(call.message.chat.id, answer) 102 | 103 | 104 | async def transfer_cashback(user_id: int, currency: str, address: str): 105 | """Transfer ``currency`` cashback of user ``user_id`` to ``address``.""" 106 | cursor = database.cashback.aggregate( 107 | [ 108 | {"$match": {"id": user_id, "currency": currency}}, 109 | {"$group": {"_id": None, "amount": {"$sum": "$amount"}}}, 110 | ] 111 | ) 112 | amount_document = await cursor.to_list(length=1) 113 | try: 114 | result = await get_escrow_instance(currency).transfer( 115 | address, 116 | amount_document[0]["amount"].to_decimal(), 117 | currency, 118 | memo="cashback for using escrow service on https://t.me/TellerBot", 119 | ) 120 | except Exception as error: 121 | raise error 122 | else: 123 | await database.cashback.delete_many({"id": user_id, "currency": currency}) 124 | return result 125 | 126 | 127 | @private_handler(state=states.cashback_address) 128 | async def claim_transfer_custom_address(message: types.Message, state: FSMContext): 129 | """Transfer cashback to custom address.""" 130 | data = await state.get_data() 131 | await tg.send_message(message.chat.id, i18n("claim_transfer_wait")) 132 | try: 133 | trx_url = await transfer_cashback( 134 | message.from_user.id, data["currency"], message.text 135 | ) 136 | except TransferError: 137 | await tg.send_message(message.chat.id, i18n("cashback_transfer_error")) 138 | else: 139 | await tg.send_message( 140 | message.chat.id, 141 | markdown.link(i18n("cashback_transferred"), trx_url), 142 | parse_mode=ParseMode.MARKDOWN, 143 | reply_markup=start_keyboard(), 144 | ) 145 | 146 | 147 | @dp.callback_query_handler( 148 | lambda call: call.data.startswith("claim_transfer "), state=any_state 149 | ) 150 | @dp.async_task 151 | async def claim_transfer(call: types.CallbackQuery): 152 | """Transfer cashback to suggested address.""" 153 | _, currency, address = call.data.split() 154 | await call.answer(i18n("claim_transfer_wait"), show_alert=True) 155 | try: 156 | trx_url = await transfer_cashback(call.from_user.id, currency, address) 157 | except TransferError: 158 | await tg.send_message( 159 | call.message.chat.id, 160 | i18n("cashback_transfer_error"), 161 | reply_markup=start_keyboard(), 162 | ) 163 | else: 164 | await tg.send_message( 165 | call.message.chat.id, 166 | markdown.link(i18n("cashback_transferred"), trx_url), 167 | reply_markup=start_keyboard(), 168 | parse_mode=ParseMode.MARKDOWN, 169 | ) 170 | -------------------------------------------------------------------------------- /CLA.md: -------------------------------------------------------------------------------- 1 | # TellerBot Individual Contributor License Agreement 2 | Thank you for your interest in contributing to TellerBot ("We" or "Us"). 3 | 4 | This contributor agreement ("Agreement") documents the rights granted by contributors to Us. To make this document effective, please sign it and send it to Us by electronic submission, following the instructions at https://github.com/fincubator/tellerbot/blob/master/CONTRIBUTING.md. This is a legally binding document, so please read it carefully before agreeing to it. The Agreement may cover more than one software project managed by Us. 5 | 6 | ## 1. Definitions 7 | "You" means the individual who Submits a Contribution to Us. 8 | 9 | "Contribution" means any work of authorship that is Submitted by You to Us in which You own or assert ownership of the Copyright. If You do not own the Copyright in the entire work of authorship, please follow the instructions in https://github.com/fincubator/tellerbot/blob/master/CONTRIBUTING.md. 10 | 11 | "Copyright" means all rights protecting works of authorship owned or controlled by You, including copyright, moral and neighboring rights, as appropriate, for the full term of their existence including any extensions by You. 12 | 13 | "Material" means the work of authorship which is made available by Us to third parties. When this Agreement covers more than one software project, the Material means the work of authorship to which the Contribution was Submitted. After You Submit the Contribution, it may be included in the Material. 14 | 15 | "Submit" means any form of electronic, verbal, or written communication sent to Us or our representatives, including but not limited to electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, Us for the purpose of discussing and improving the Material, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution." 16 | 17 | "Submission Date" means the date on which You Submit a Contribution to Us. 18 | 19 | "Effective Date" means the date You execute this Agreement or the date You first Submit a Contribution to Us, whichever is earlier. 20 | 21 | ## 2. Grant of Rights 22 | **2.1 Copyright License** 23 | 24 | (a) You retain ownership of the Copyright in Your Contribution and have the same rights to use or license the Contribution which You would have had without entering into the Agreement. 25 | 26 | (b) To the maximum extent permitted by the relevant law, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable license under the Copyright covering the Contribution, with the right to sublicense such rights through multiple tiers of sublicensees, to reproduce, modify, display, perform and distribute the Contribution as part of the Material; provided that this license is conditioned upon compliance with Section 2.3. 27 | 28 | **2.2 Patent License** 29 | For patent claims including, without limitation, method, process, and apparatus claims which You own, control or have the right to grant, now or in the future, You grant to Us a perpetual, worldwide, non-exclusive, transferable, royalty-free, irrevocable patent license, with the right to sublicense these rights to multiple tiers of sublicensees, to make, have made, use, sell, offer for sale, import and otherwise transfer the Contribution and the Contribution in combination with the Material (and portions of such combination). This license is granted only to the extent that the exercise of the licensed rights infringes such patent claims; and provided that this license is conditioned upon compliance with Section 2.3. 30 | 31 | **2.3 Outbound License** 32 | Based on the grant of rights in Sections 2.1 and 2.2, if We include Your Contribution in a Material, We may license the Contribution under any license, including copyleft, permissive, commercial, or proprietary licenses. As a condition on the exercise of this right, We agree to also license the Contribution under the terms of the license or licenses which We are using for the Material on the Submission Date. 33 | 34 | **2.4 Moral Rights**. If moral rights apply to the Contribution, to the maximum extent permitted by law, You waive and agree not to assert such moral rights against Us or our successors in interest, or any of our licensees, either direct or indirect. 35 | 36 | **2.5 Our Rights**. You acknowledge that We are not obligated to use Your Contribution as part of the Material and may decide to include any Contribution We consider appropriate. 37 | 38 | **2.6 Reservation of Rights**. Any rights not expressly licensed under this section are expressly reserved by You. 39 | 40 | ## 3. Agreement 41 | You confirm that: 42 | 43 | (a) You have the legal authority to enter into this Agreement. 44 | 45 | (b) You own the Copyright and patent claims covering the Contribution which are required to grant the rights under Section 2. 46 | 47 | (c) The grant of rights under Section 2 does not violate any grant of rights which You have made to third parties, including Your employer. If You are an employee, You have had Your employer approve this Agreement or sign the Entity version of this document. If You are less than eighteen years old, please have Your parents or guardian sign the Agreement. 48 | 49 | (d) You have followed the instructions in https://github.com/fincubator/tellerbot/blob/master/CONTRIBUTING.md, if You do not own the Copyright in the entire work of authorship Submitted. 50 | 51 | ## 4. Disclaimer 52 | EXCEPT FOR THE EXPRESS WARRANTIES IN SECTION 3, THE CONTRIBUTION IS PROVIDED "AS IS". MORE PARTICULARLY, ALL EXPRESS OR IMPLIED WARRANTIES INCLUDING, WITHOUT LIMITATION, ANY IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT ARE EXPRESSLY DISCLAIMED BY YOU TO US. TO THE EXTENT THAT ANY SUCH WARRANTIES CANNOT BE DISCLAIMED, SUCH WARRANTY IS LIMITED IN DURATION TO THE MINIMUM PERIOD PERMITTED BY LAW. 53 | 54 | ## 5. Consequential Damage Waiver 55 | TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN NO EVENT WILL YOU BE LIABLE FOR ANY LOSS OF PROFITS, LOSS OF ANTICIPATED SAVINGS, LOSS OF DATA, INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL AND EXEMPLARY DAMAGES ARISING OUT OF THIS AGREEMENT REGARDLESS OF THE LEGAL OR EQUITABLE THEORY (CONTRACT, TORT OR OTHERWISE) UPON WHICH THE CLAIM IS BASED. 56 | 57 | ## 6. Miscellaneous 58 | 6.1 This Agreement will be governed by and construed in accordance with the laws of TellerBot excluding its conflicts of law provisions. Under certain circumstances, the governing law in this section might be superseded by the United Nations Convention on Contracts for the International Sale of Goods ("UN Convention") and the parties intend to avoid the application of the UN Convention to this Agreement and, thus, exclude the application of the UN Convention in its entirety to this Agreement. 59 | 60 | 6.2 This Agreement sets out the entire agreement between You and Us for Your Contributions to Us and overrides all other agreements or understandings. 61 | 62 | 6.3 If You or We assign the rights or obligations received through this Agreement to a third party, as a condition of the assignment, that third party must agree in writing to abide by all the rights and obligations in the Agreement. 63 | 64 | 6.4 The failure of either party to require performance by the other party of any provision of this Agreement in one situation shall not affect the right of a party to require such performance at any time in the future. A waiver of performance under a provision in one situation shall not be considered a waiver of the performance of the provision in the future or a waiver of the provision in its entirety. 65 | 66 | 6.5 If any provision of this Agreement is found void and unenforceable, such provision will be replaced to the extent possible with a provision that comes closest to the meaning of the original provision and which is enforceable. The terms and conditions set forth in this Agreement shall apply notwithstanding any failure of essential purpose of this Agreement or any limited remedy to the maximum extent possible under law. 67 | -------------------------------------------------------------------------------- /src/escrow/blockchain/golos_blockchain.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import functools 18 | import json 19 | import typing 20 | from asyncio import get_running_loop 21 | from asyncio import sleep 22 | from calendar import timegm 23 | from datetime import datetime 24 | from decimal import Decimal 25 | from time import time 26 | 27 | from golos import Api 28 | from golos.exceptions import GolosException 29 | from golos.exceptions import RetriesExceeded 30 | from golos.exceptions import TransactionNotFound 31 | 32 | from src.escrow.blockchain import BlockchainConnectionError 33 | from src.escrow.blockchain import InsuranceLimits 34 | from src.escrow.blockchain import StreamBlockchain 35 | from src.escrow.blockchain import TransferError 36 | 37 | 38 | class GolosBlockchain(StreamBlockchain): 39 | """Golos node client implementation for escrow exchange.""" 40 | 41 | name = "golos" 42 | assets = frozenset(["GOLOS", "GBG"]) 43 | address = "tellerbot" 44 | explorer = "https://golos.cf/tx/?={}" 45 | 46 | async def connect(self): 47 | loop = get_running_loop() 48 | connect_to_node = functools.partial(Api, nodes=self.nodes) 49 | try: 50 | self._golos = await loop.run_in_executor(None, connect_to_node) 51 | self._stream = await loop.run_in_executor(None, connect_to_node) 52 | self._stream.rpc.api_total["set_block_applied_callback"] = "database_api" 53 | except RetriesExceeded as exception: 54 | raise BlockchainConnectionError(exception) 55 | 56 | queue = await self.create_queue() 57 | if not queue: 58 | return 59 | min_time = self.get_min_time(queue) 60 | 61 | func = functools.partial( 62 | self._golos.get_account_history, 63 | self.address, 64 | op_limit="transfer", 65 | age=int(time() - min_time), 66 | ) 67 | history = await get_running_loop().run_in_executor(None, func) 68 | for op in history: 69 | req = await self._check_operation(op, op["block"], queue) 70 | if not req: 71 | continue 72 | is_confirmed = await self._confirmation_callback( 73 | req["offer_id"], op, op["trx_id"], op["block"] 74 | ) 75 | if is_confirmed: 76 | queue.remove(req) 77 | if not queue: 78 | return 79 | self._queue.extend(queue) 80 | 81 | async def get_limits(self, asset: str): 82 | limits = {"GOLOS": InsuranceLimits(Decimal("10000"), Decimal("100000"))} 83 | return limits.get(asset) 84 | 85 | async def transfer(self, to: str, amount: Decimal, asset: str, memo: str = ""): 86 | try: 87 | transaction = await get_running_loop().run_in_executor( 88 | None, 89 | self._golos.transfer, 90 | to.lower(), 91 | amount, 92 | self.address, 93 | self.wif, 94 | asset, 95 | memo, 96 | ) 97 | except GolosException: 98 | raise TransferError 99 | return self.trx_url(transaction["id"]) 100 | 101 | async def is_block_confirmed(self, block_num, op): 102 | loop = get_running_loop() 103 | while True: 104 | properties = await loop.run_in_executor( 105 | None, self._golos.get_dynamic_global_properties 106 | ) 107 | if properties: 108 | head_block_num = properties["last_irreversible_block_num"] 109 | if block_num <= head_block_num: 110 | break 111 | await sleep(3) 112 | op = { 113 | "block": block_num, 114 | "type_op": "transfer", 115 | "to": op["to"], 116 | "from": op["from"], 117 | "amount": op["amount"], 118 | "memo": op["memo"], 119 | } 120 | try: 121 | await loop.run_in_executor(None, self._golos.find_op_transaction, op) 122 | except TransactionNotFound: 123 | return False 124 | else: 125 | return True 126 | 127 | async def stream(self): 128 | loop = get_running_loop() 129 | block = await loop.run_in_executor( 130 | None, self._stream.rpc.call, "set_block_applied_callback", [0] 131 | ) 132 | while True: 133 | for trx in block["transactions"]: 134 | for op_type, op in trx["operations"]: 135 | if op_type != "transfer": 136 | continue 137 | block_num = int(block["previous"][:8], 16) + 1 138 | req = await self._check_operation(op, block_num) 139 | if not req: 140 | continue 141 | trx_id = await loop.run_in_executor( 142 | None, self._golos.get_transaction_id, trx 143 | ) 144 | is_confirmed = await self._confirmation_callback( 145 | req["offer_id"], op, trx_id, block_num 146 | ) 147 | if is_confirmed: 148 | self._queue.remove(req) 149 | if not self._queue: 150 | await loop.run_in_executor(None, self._stream.rpc.close) 151 | return 152 | while True: 153 | response = await loop.run_in_executor(None, self._stream.rpc.ws.recv) 154 | response_json = json.loads(response) 155 | if "error" not in response_json: 156 | break 157 | block = response_json["result"] 158 | 159 | async def _check_operation( 160 | self, 161 | op: typing.Mapping[str, typing.Any], 162 | block_num: int, 163 | queue: typing.Optional[typing.List] = None, 164 | ): 165 | if queue is None: 166 | queue = self._queue 167 | op_amount, asset = op["amount"].split() 168 | amount = Decimal(op_amount) 169 | for req in queue: 170 | if "timestamp" in op: 171 | date = datetime.strptime(op["timestamp"], "%Y-%m-%dT%H:%M:%S") 172 | if timegm(date.timetuple()) < req["transaction_time"]: 173 | continue 174 | if op["to"] != self.address or op["from"] != req["from_address"].lower(): 175 | continue 176 | if "timeout_handler" in req: 177 | req["timeout_handler"].cancel() 178 | refund_reasons = set() 179 | if asset != req["asset"]: 180 | refund_reasons.add("asset") 181 | if amount not in (req["amount_with_fee"], req["amount_without_fee"]): 182 | refund_reasons.add("amount") 183 | if op["memo"] != req["memo"]: 184 | refund_reasons.add("memo") 185 | if not refund_reasons: 186 | return req 187 | await self._refund_callback( 188 | frozenset(refund_reasons), 189 | req["offer_id"], 190 | op, 191 | op["from"], 192 | amount, 193 | asset, 194 | block_num, 195 | ) 196 | -------------------------------------------------------------------------------- /src/escrow/blockchain/cyber_blockchain.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import json 18 | import typing 19 | from asyncio import sleep 20 | from calendar import timegm 21 | from datetime import datetime 22 | from datetime import timedelta 23 | from decimal import Decimal 24 | from urllib.parse import urljoin 25 | 26 | import aiohttp 27 | from eospy import schema 28 | from eospy import types 29 | from eospy.keys import EOSKey 30 | from eospy.utils import sig_digest 31 | 32 | from src.config import config 33 | from src.escrow.blockchain import BaseBlockchain 34 | from src.escrow.blockchain import BlockchainConnectionError 35 | from src.escrow.blockchain import InsuranceLimits 36 | from src.escrow.blockchain import TransferError 37 | 38 | 39 | TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%f" 40 | 41 | 42 | class MaxRamKbytesSchema(schema.IntSchema): 43 | """RAM as an additional CyberWay resource.""" 44 | 45 | default = 0 46 | missing = 0 47 | 48 | 49 | class MaxStorageKbytesSchema(schema.IntSchema): 50 | """Storage as an additional CyberWay resource.""" 51 | 52 | default = 0 53 | missing = 0 54 | 55 | 56 | class CyberWayTransactionSchema(schema.TransactionSchema): 57 | """CyberWay transaction schema based on EOS schema with additional resources.""" 58 | 59 | max_ram_kbytes = MaxRamKbytesSchema() 60 | max_storage_kbytes = MaxRamKbytesSchema() 61 | 62 | 63 | class CyberWayTransaction(types.Transaction): 64 | """CyberWay transaction class based on EOS transaction.""" 65 | 66 | def __init__(self, d, chain_info, lib_info): 67 | """EOS initialization with Cyberway compatible schema validator.""" 68 | if "expiration" not in d: 69 | d["expiration"] = str(datetime.utcnow() + timedelta(seconds=30)) 70 | if "ref_block_num" not in d: 71 | d["ref_block_num"] = chain_info["last_irreversible_block_num"] & 0xFFFF 72 | if "ref_block_prefix" not in d: 73 | d["ref_block_prefix"] = lib_info["ref_block_prefix"] 74 | self._validator = CyberWayTransactionSchema() 75 | super(types.Transaction, self).__init__(d) 76 | self.actions = self._create_obj_array(self.actions, types.Action) 77 | 78 | def _encode_hdr(self): 79 | exp_diff = self.expiration - datetime(1970, 1, 1, tzinfo=self.expiration.tzinfo) 80 | exp = self._encode_buffer(types.UInt32(exp_diff.total_seconds())) 81 | ref_blk = self._encode_buffer(types.UInt16(self.ref_block_num & 0xFFFF)) 82 | ref_block_prefix = self._encode_buffer(types.UInt32(self.ref_block_prefix)) 83 | net_usage_words = self._encode_buffer(types.VarUInt(self.net_usage_words)) 84 | max_cpu_usage_ms = self._encode_buffer(types.Byte(self.max_cpu_usage_ms)) 85 | max_ram_kbytes = self._encode_buffer(types.VarUInt(self.max_ram_kbytes)) 86 | max_storage_kbytes = self._encode_buffer(types.VarUInt(self.max_storage_kbytes)) 87 | delay_sec = self._encode_buffer(types.VarUInt(self.delay_sec)) 88 | return ( 89 | f"{exp}" 90 | f"{ref_blk}" 91 | f"{ref_block_prefix}" 92 | f"{net_usage_words}" 93 | f"{max_cpu_usage_ms}" 94 | f"{max_ram_kbytes}" 95 | f"{max_storage_kbytes}" 96 | f"{delay_sec}" 97 | ) 98 | 99 | 100 | class CyberBlockchain(BaseBlockchain): 101 | """Golos node client implementation for escrow exchange.""" 102 | 103 | name = "cyber" 104 | assets = frozenset(["CYBER", "CYBER.GOLOS"]) 105 | address = "usr11jwlrakn" 106 | explorer = "https://explorer.cyberway.io/trx/{}" 107 | 108 | async def connect(self): 109 | self._session = aiohttp.ClientSession( 110 | raise_for_status=True, timeout=aiohttp.ClientTimeout(total=30) 111 | ) 112 | with open(config.ESCROW_FILENAME) as escrow_file: 113 | nodes = json.load(escrow_file)["cyber"]["nodes"] 114 | for node in nodes: 115 | try: 116 | async with self._session.get(urljoin(node, "v1/chain/get_info")): 117 | self._node = node 118 | break 119 | except (aiohttp.ClientResponseError, aiohttp.ClientConnectorError): 120 | continue 121 | else: 122 | raise BlockchainConnectionError("Couldn't connect to any node") 123 | 124 | queue = await self.create_queue() 125 | if not queue: 126 | return 127 | await self._check_queue_in_history(queue) 128 | 129 | async def check_transaction(self, **kwargs) -> bool: 130 | return await self._check_queue_in_history([kwargs]) 131 | 132 | async def get_limits(self, asset: str): 133 | return InsuranceLimits(Decimal("10000"), Decimal("100000")) 134 | 135 | async def transfer(self, to: str, amount: Decimal, asset: str, memo: str = ""): 136 | if "." in asset: 137 | asset = asset.split(".")[1] 138 | if asset == "CYBER": 139 | formatted_amount = f"{amount:.4f}" 140 | elif asset == "GOLOS": 141 | formatted_amount = f"{amount:.3f}" 142 | arguments = { 143 | "from": self.address, 144 | "to": await self._resolve_address(to), 145 | "quantity": f"{formatted_amount} {asset}", 146 | "memo": memo, 147 | } 148 | payload = { 149 | "account": "cyber.token", 150 | "name": "transfer", 151 | "authorization": [{"actor": self.address, "permission": "active"}], 152 | } 153 | payload_data = await self._api( 154 | "v1/chain/abi_json_to_bin", 155 | data={ 156 | "code": payload["account"], 157 | "action": payload["name"], 158 | "args": arguments, 159 | }, 160 | ) 161 | payload["data"] = payload_data["binargs"] 162 | chain_info = await self._api("v1/chain/get_info") 163 | lib_info = await self._api( 164 | "v1/chain/get_block", 165 | data={"block_num_or_id": chain_info["last_irreversible_block_num"]}, 166 | ) 167 | trx = CyberWayTransaction({"actions": [payload]}, chain_info, lib_info) 168 | digest = sig_digest(trx.encode(), chain_info["chain_id"]) 169 | transaction = trx.__dict__ 170 | transaction["expiration"] = trx.expiration.strftime(TIME_FORMAT)[:-3] 171 | final_trx = { 172 | "compression": "none", 173 | "transaction": transaction, 174 | "signatures": [EOSKey(self.wif).sign(digest)], 175 | } 176 | trx_data = json.dumps(final_trx, cls=types.EOSEncoder) 177 | try: 178 | result = await self._api("v1/chain/push_transaction", data=trx_data) 179 | except aiohttp.ClientResponseError: 180 | raise TransferError 181 | return self.trx_url(result["transaction_id"]) 182 | 183 | async def is_block_confirmed(self, block_num, op): 184 | while True: 185 | try: 186 | info = await self._api("v1/chain/get_info") 187 | except aiohttp.ClientResponseError: 188 | continue 189 | if block_num <= info["last_irreversible_block_num"]: 190 | break 191 | await sleep(3) 192 | try: 193 | await self._api( 194 | "v1/history/get_transaction", 195 | data={"id": op["trx_id"], "block_num_hint": block_num}, 196 | ) 197 | except aiohttp.ClientResponseError: 198 | return False 199 | else: 200 | return True 201 | 202 | async def close(self): 203 | if hasattr(self, "_session"): 204 | await self._session.close() 205 | 206 | async def _api( 207 | self, 208 | method: str, 209 | *, 210 | data: typing.Union[None, str, typing.Sequence, typing.Mapping] = None, 211 | **kwargs, 212 | ) -> typing.Dict[str, typing.Any]: 213 | url = urljoin(self._node, method) 214 | if data is not None and not isinstance(data, str): 215 | data = json.dumps(data) 216 | async with self._session.post(url, data=data, **kwargs) as resp: 217 | return await resp.json() 218 | 219 | async def _resolve_addresses( 220 | self, addresses: typing.List[str] 221 | ) -> typing.Dict[str, str]: 222 | # Change golos address to cyberway address 223 | result = {} 224 | data = [f"{address}@golos" for address in addresses] 225 | while True: 226 | cyberway_usernames = await self._api( 227 | "v1/chain/resolve_names", data=data, raise_for_status=False 228 | ) 229 | if isinstance(cyberway_usernames, dict): 230 | error_message = cyberway_usernames["error"]["details"][-1]["message"] 231 | error_element = error_message.split()[-1] 232 | data = [element for element in data if element != error_element] 233 | cyberway_username = error_element.split("@")[0] 234 | result[cyberway_username] = cyberway_username 235 | if not data: 236 | break 237 | else: 238 | for element, cyberway_username in zip(data, cyberway_usernames): 239 | golos_address = element.split("@")[0] 240 | result[golos_address] = cyberway_username["resolved_username"] 241 | break 242 | return result 243 | 244 | async def _resolve_address(self, address) -> str: 245 | address = address.lower() 246 | addresses = await self._resolve_addresses([address]) 247 | return addresses[address] 248 | 249 | async def _check_queue_in_history( 250 | self, 251 | queue: typing.List[typing.Dict[str, typing.Any]], 252 | ) -> bool: 253 | addresses = [queue_member["from_address"].lower() for queue_member in queue] 254 | resolved = await self._resolve_addresses(addresses) 255 | for queue_member, address in zip(queue, addresses): 256 | queue_member["from_address"] = resolved[address] 257 | 258 | pos = -1 259 | min_time = self.get_min_time(queue) 260 | while True: 261 | history = await self._api( 262 | "v1/history/get_actions", 263 | data={"account_name": self.address, "pos": pos, "offset": -20}, 264 | ) 265 | if not history["actions"]: 266 | return False 267 | for act in reversed(history["actions"]): 268 | if act["action_trace"]["act"]["name"] != "transfer": 269 | continue 270 | date = datetime.strptime(act["block_time"], TIME_FORMAT) 271 | if timegm(date.timetuple()) < min_time: 272 | return False 273 | op = act["action_trace"]["act"]["data"] 274 | op["timestamp"] = act["block_time"] 275 | op["trx_id"] = act["action_trace"]["trx_id"] 276 | req = await self._check_operation(op, act["block_num"], queue) 277 | if not req: 278 | continue 279 | await self._confirmation_callback( 280 | req["offer_id"], op, op["trx_id"], act["block_num"] 281 | ) 282 | if len(queue) == 1: 283 | return True 284 | pos = history["actions"][0]["account_action_seq"] - 1 285 | 286 | async def _check_operation( 287 | self, 288 | op: typing.Mapping[str, typing.Any], 289 | block_num: int, 290 | queue: typing.List[typing.Dict[str, typing.Any]], 291 | ): 292 | op_amount, asset = op["quantity"].split() 293 | amount = Decimal(op_amount) 294 | for req in queue: 295 | if "timestamp" in op: 296 | date = datetime.strptime(op["timestamp"], TIME_FORMAT) 297 | if timegm(date.timetuple()) < req["transaction_time"]: 298 | continue 299 | if op["to"] != self.address or op["from"] != req["from_address"]: 300 | continue 301 | req_asset = req["asset"] 302 | if "." in req_asset: 303 | req_asset = req_asset.split(".")[1] 304 | refund_reasons = set() 305 | if asset != req_asset: 306 | refund_reasons.add("asset") 307 | if amount not in (req["amount_with_fee"], req["amount_without_fee"]): 308 | refund_reasons.add("amount") 309 | if op["memo"] != req["memo"]: 310 | refund_reasons.add("memo") 311 | if not refund_reasons: 312 | return req 313 | await self._refund_callback( 314 | frozenset(refund_reasons), 315 | req["offer_id"], 316 | op, 317 | op["from"], 318 | amount, 319 | asset, 320 | block_num, 321 | ) 322 | -------------------------------------------------------------------------------- /locale/tr/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # English translations for TellerBot. 2 | # Copyright (C) 2019 Fincubator 3 | # This file is distributed under the same license as the TellerBot project. 4 | # alfred richardsn , 2019. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: TellerBot\n" 9 | "Report-Msgid-Bugs-To: rchrdsn@protonmail.ch\n" 10 | "POT-Creation-Date: 2020-06-13 02:06+0300\n" 11 | "PO-Revision-Date: 2020-06-17 12:41+0000\n" 12 | "Last-Translator: Automatically generated\n" 13 | "Language-Team: none\n" 14 | "Language: tr\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=n != 1;\n" 19 | "X-Generator: Weblate 4.1.1-dev\n" 20 | "Generated-By: Babel 2.8.0\n" 21 | 22 | #: src/escrow/blockchain/__init__.py 23 | msgid "check_timeout {hours}" 24 | msgstr "" 25 | 26 | #: src/escrow/blockchain/__init__.py 27 | msgid "transaction_passed {currency}" 28 | msgstr "" 29 | 30 | #: src/escrow/blockchain/__init__.py src/handlers/escrow.py 31 | msgid "sent" 32 | msgstr "" 33 | 34 | #: src/escrow/blockchain/__init__.py 35 | msgid "transaction_confirmed" 36 | msgstr "" 37 | 38 | #: src/escrow/blockchain/__init__.py src/handlers/escrow.py 39 | msgid "send {amount} {currency} {address}" 40 | msgstr "" 41 | 42 | #: src/escrow/blockchain/__init__.py 43 | msgid "transaction_not_confirmed" 44 | msgstr "" 45 | 46 | #: src/escrow/blockchain/__init__.py 47 | msgid "try_again" 48 | msgstr "" 49 | 50 | #: src/escrow/blockchain/__init__.py 51 | msgid "transfer_mistakes" 52 | msgstr "" 53 | 54 | #: src/escrow/blockchain/__init__.py 55 | msgid "wrong_asset" 56 | msgstr "" 57 | 58 | #: src/escrow/blockchain/__init__.py 59 | msgid "wrong_amount" 60 | msgstr "" 61 | 62 | #: src/escrow/blockchain/__init__.py 63 | msgid "wrong_memo" 64 | msgstr "" 65 | 66 | #: src/escrow/blockchain/__init__.py 67 | msgid "refund_promise" 68 | msgstr "" 69 | 70 | #: src/escrow/blockchain/__init__.py 71 | msgid "transaction_refunded" 72 | msgstr "" 73 | 74 | #: src/handlers/__init__.py 75 | msgid "unknown_command" 76 | msgstr "" 77 | 78 | #: src/handlers/__init__.py 79 | msgid "unknown_button" 80 | msgstr "" 81 | 82 | #: src/handlers/__init__.py 83 | msgid "unexpected_error" 84 | msgstr "" 85 | 86 | #: src/handlers/base.py 87 | msgid "create_order" 88 | msgstr "" 89 | 90 | #: src/handlers/base.py 91 | msgid "my_orders" 92 | msgstr "" 93 | 94 | #: src/handlers/base.py 95 | msgid "order_book" 96 | msgstr "" 97 | 98 | #: src/handlers/base.py 99 | msgid "language" 100 | msgstr "" 101 | 102 | #: src/handlers/base.py 103 | msgid "support" 104 | msgstr "" 105 | 106 | #: src/handlers/base.py src/whitelist.py 107 | msgid "back" 108 | msgstr "" 109 | 110 | #: src/handlers/base.py 111 | msgid "skip" 112 | msgstr "" 113 | 114 | #: src/handlers/base.py src/handlers/creation.py src/handlers/escrow.py 115 | #: src/handlers/order.py src/handlers/start_menu.py src/whitelist.py 116 | msgid "cancel" 117 | msgstr "" 118 | 119 | #: src/handlers/base.py 120 | msgid "no_orders" 121 | msgstr "" 122 | 123 | #: src/handlers/base.py src/handlers/creation.py 124 | msgid "invert" 125 | msgstr "" 126 | 127 | #: src/handlers/base.py 128 | msgid "page {number} {total}" 129 | msgstr "" 130 | 131 | #: src/handlers/base.py 132 | msgid "buy_amount" 133 | msgstr "" 134 | 135 | #: src/handlers/base.py 136 | msgid "sell_amount" 137 | msgstr "" 138 | 139 | #: src/handlers/base.py 140 | msgid "price" 141 | msgstr "" 142 | 143 | #: src/handlers/base.py 144 | msgid "payment_system" 145 | msgstr "" 146 | 147 | #: src/handlers/base.py 148 | msgid "duration" 149 | msgstr "" 150 | 151 | #: src/handlers/base.py 152 | msgid "comments" 153 | msgstr "" 154 | 155 | #: src/handlers/base.py src/handlers/order.py 156 | msgid "new_price {of_currency} {per_currency}" 157 | msgstr "" 158 | 159 | #: src/handlers/base.py 160 | msgid "archived" 161 | msgstr "" 162 | 163 | #: src/handlers/base.py 164 | msgid "sells {sell_currency} {buy_currency}" 165 | msgstr "" 166 | 167 | #: src/handlers/base.py 168 | msgid "buys {buy_currency} {sell_currency}" 169 | msgstr "" 170 | 171 | #: src/handlers/base.py 172 | msgid "finish" 173 | msgstr "" 174 | 175 | #: src/handlers/base.py 176 | msgid "similar" 177 | msgstr "" 178 | 179 | #: src/handlers/base.py 180 | msgid "match" 181 | msgstr "" 182 | 183 | #: src/handlers/base.py 184 | msgid "edit" 185 | msgstr "" 186 | 187 | #: src/handlers/base.py 188 | msgid "delete" 189 | msgstr "" 190 | 191 | #: src/handlers/base.py 192 | msgid "unarchive" 193 | msgstr "" 194 | 195 | #: src/handlers/base.py 196 | msgid "archive" 197 | msgstr "" 198 | 199 | #: src/handlers/base.py 200 | msgid "change_duration" 201 | msgstr "" 202 | 203 | #: src/handlers/base.py 204 | msgid "escrow" 205 | msgstr "" 206 | 207 | #: src/handlers/base.py src/handlers/order.py 208 | msgid "hide" 209 | msgstr "" 210 | 211 | #: src/handlers/base.py src/handlers/order.py 212 | msgid "unset" 213 | msgstr "" 214 | 215 | #: src/handlers/creation.py 216 | msgid "wrong_button" 217 | msgstr "" 218 | 219 | #: src/handlers/creation.py 220 | msgid "back_error" 221 | msgstr "" 222 | 223 | #: src/handlers/creation.py 224 | msgid "skip_error" 225 | msgstr "" 226 | 227 | #: src/handlers/creation.py 228 | msgid "no_creation" 229 | msgstr "" 230 | 231 | #: src/handlers/creation.py 232 | msgid "order_cancelled" 233 | msgstr "" 234 | 235 | #: src/handlers/creation.py src/handlers/escrow.py src/handlers/order.py 236 | msgid "exceeded_character_limit {limit} {sent}" 237 | msgstr "" 238 | 239 | #: src/handlers/creation.py 240 | msgid "non_latin_characters_gateway" 241 | msgstr "" 242 | 243 | #: src/handlers/creation.py 244 | msgid "request_whitelisting" 245 | msgstr "" 246 | 247 | #: src/handlers/creation.py 248 | msgid "gateway_not_whitelisted {currency}" 249 | msgstr "" 250 | 251 | #: src/handlers/creation.py 252 | msgid "non_latin_characters_currency" 253 | msgstr "" 254 | 255 | #: src/handlers/creation.py 256 | msgid "no_fiat_gateway" 257 | msgstr "" 258 | 259 | #: src/handlers/creation.py 260 | msgid "choose_gateway {currency}" 261 | msgstr "" 262 | 263 | #: src/handlers/creation.py 264 | msgid "currency_not_whitelisted" 265 | msgstr "" 266 | 267 | #: src/handlers/creation.py 268 | msgid "double_request" 269 | msgstr "" 270 | 271 | #: src/handlers/creation.py 272 | msgid "request_sent" 273 | msgstr "" 274 | 275 | #: src/handlers/creation.py 276 | msgid "ask_sell_currency" 277 | msgstr "" 278 | 279 | #: src/handlers/creation.py src/handlers/start_menu.py 280 | msgid "ask_buy_currency" 281 | msgstr "" 282 | 283 | #: src/handlers/creation.py 284 | msgid "ask_buy_price {of_currency} {per_currency}" 285 | msgstr "" 286 | 287 | #: src/handlers/creation.py 288 | msgid "same_currency_error" 289 | msgstr "" 290 | 291 | #: src/handlers/creation.py 292 | msgid "same_gateway_error" 293 | msgstr "" 294 | 295 | #: src/handlers/creation.py 296 | msgid "ask_sell_price {of_currency} {per_currency}" 297 | msgstr "" 298 | 299 | #: src/handlers/creation.py 300 | msgid "ask_sum_currency" 301 | msgstr "" 302 | 303 | #: src/handlers/creation.py 304 | msgid "ask_order_sum {currency}" 305 | msgstr "" 306 | 307 | #: src/handlers/creation.py 308 | msgid "choose_sum_currency_with_buttons" 309 | msgstr "" 310 | 311 | #: src/handlers/creation.py 312 | msgid "ask_location" 313 | msgstr "" 314 | 315 | #: src/handlers/creation.py 316 | msgid "cashless_payment_system" 317 | msgstr "" 318 | 319 | #: src/handlers/creation.py 320 | msgid "location_not_found" 321 | msgstr "" 322 | 323 | #: src/handlers/creation.py 324 | msgid "ask_duration {limit}" 325 | msgstr "" 326 | 327 | #: src/handlers/creation.py 328 | msgid "choose_location" 329 | msgstr "" 330 | 331 | #: src/handlers/creation.py src/handlers/order.py 332 | msgid "send_natural_number" 333 | msgstr "" 334 | 335 | #: src/handlers/creation.py src/handlers/order.py 336 | msgid "exceeded_duration_limit {limit}" 337 | msgstr "" 338 | 339 | #: src/handlers/creation.py 340 | msgid "ask_comments" 341 | msgstr "" 342 | 343 | #: src/handlers/creation.py 344 | msgid "order_set" 345 | msgstr "" 346 | 347 | #: src/handlers/escrow.py 348 | msgid "send_at_least_8_digits" 349 | msgstr "" 350 | 351 | #: src/handlers/escrow.py 352 | msgid "digits_parsing_error" 353 | msgstr "" 354 | 355 | #: src/handlers/escrow.py 356 | msgid "offer_not_active" 357 | msgstr "" 358 | 359 | #: src/handlers/escrow.py 360 | msgid "exceeded_order_sum" 361 | msgstr "" 362 | 363 | #: src/handlers/escrow.py 364 | msgid "continue" 365 | msgstr "" 366 | 367 | #: src/handlers/escrow.py 368 | msgid "exceeded_insurance {amount} {currency}" 369 | msgstr "" 370 | 371 | #: src/handlers/escrow.py 372 | msgid "exceeded_insurance_options" 373 | msgstr "" 374 | 375 | #: src/handlers/escrow.py 376 | msgid "ask_fee {fee_percents}" 377 | msgstr "" 378 | 379 | #: src/handlers/escrow.py 380 | msgid "will_pay {amount} {currency}" 381 | msgstr "" 382 | 383 | #: src/handlers/escrow.py 384 | msgid "will_get {amount} {currency}" 385 | msgstr "" 386 | 387 | #: src/handlers/escrow.py 388 | msgid "yes" 389 | msgstr "" 390 | 391 | #: src/handlers/escrow.py src/handlers/order.py 392 | msgid "no" 393 | msgstr "" 394 | 395 | #: src/handlers/escrow.py 396 | msgid "escrow_cancelled" 397 | msgstr "" 398 | 399 | #: src/handlers/escrow.py 400 | msgid "choose_bank" 401 | msgstr "" 402 | 403 | #: src/handlers/escrow.py 404 | msgid "request_full_card_number {currency} {user}" 405 | msgstr "" 406 | 407 | #: src/handlers/escrow.py 408 | msgid "asked_full_card_number {user}" 409 | msgstr "" 410 | 411 | #: src/handlers/escrow.py 412 | msgid "ask_address {currency}" 413 | msgstr "" 414 | 415 | #: src/handlers/escrow.py 416 | msgid "bank_not_supported" 417 | msgstr "" 418 | 419 | #: src/handlers/escrow.py 420 | msgid "send_first_and_last_4_digits_of_card_number {currency}" 421 | msgstr "" 422 | 423 | #: src/handlers/escrow.py 424 | msgid "wrong_full_card_number_receiver {user}" 425 | msgstr "" 426 | 427 | #: src/handlers/escrow.py 428 | msgid "exchange_continued {user}" 429 | msgstr "" 430 | 431 | #: src/handlers/escrow.py 432 | msgid "send_name_patronymic_surname" 433 | msgstr "" 434 | 435 | #: src/handlers/escrow.py 436 | msgid "wrong_word_count {word_count}" 437 | msgstr "" 438 | 439 | #: src/handlers/escrow.py 440 | msgid "show_order" 441 | msgstr "" 442 | 443 | #: src/handlers/escrow.py 444 | msgid "accept" 445 | msgstr "" 446 | 447 | #: src/handlers/escrow.py 448 | msgid "decline" 449 | msgstr "" 450 | 451 | #: src/handlers/escrow.py 452 | msgid "" 453 | "escrow_offer_notification {user} {sell_amount} {sell_currency} for " 454 | "{buy_amount} {buy_currency}" 455 | msgstr "" 456 | 457 | #: src/handlers/escrow.py 458 | msgid "using {bank}" 459 | msgstr "" 460 | 461 | #: src/handlers/escrow.py 462 | msgid "offer_sent" 463 | msgstr "" 464 | 465 | #: src/handlers/escrow.py 466 | msgid "escrow_offer_declined" 467 | msgstr "" 468 | 469 | #: src/handlers/escrow.py 470 | msgid "offer_declined" 471 | msgstr "" 472 | 473 | #: src/handlers/escrow.py 474 | msgid "transaction_check_starting" 475 | msgstr "" 476 | 477 | #: src/handlers/escrow.py 478 | msgid "transaction_not_found" 479 | msgstr "" 480 | 481 | #: src/handlers/escrow.py 482 | msgid "check" 483 | msgstr "" 484 | 485 | #: src/handlers/escrow.py 486 | msgid "with_memo" 487 | msgstr "" 488 | 489 | #: src/handlers/escrow.py 490 | msgid "transfer_information_sent" 491 | msgstr "" 492 | 493 | #: src/handlers/escrow.py 494 | msgid "transaction_completion_notification_promise" 495 | msgstr "" 496 | 497 | #: src/handlers/escrow.py 498 | msgid "cancel_after_transfer" 499 | msgstr "" 500 | 501 | #: src/handlers/escrow.py 502 | msgid "cancel_before_verification" 503 | msgstr "" 504 | 505 | #: src/handlers/escrow.py 506 | msgid "transfer_already_confirmed" 507 | msgstr "" 508 | 509 | #: src/handlers/escrow.py 510 | msgid "receiving_confirmation {currency} {user}" 511 | msgstr "" 512 | 513 | #: src/handlers/escrow.py 514 | msgid "complete_escrow_promise" 515 | msgstr "" 516 | 517 | #: src/handlers/escrow.py 518 | msgid "escrow_completing" 519 | msgstr "" 520 | 521 | #: src/handlers/escrow.py 522 | msgid "escrow_completed" 523 | msgstr "" 524 | 525 | #: src/handlers/escrow.py 526 | msgid "escrow_sent {amount} {currency}" 527 | msgstr "" 528 | 529 | #: src/handlers/escrow.py 530 | msgid "request_validation_promise" 531 | msgstr "" 532 | 533 | #: src/handlers/order.py 534 | msgid "order_not_found" 535 | msgstr "" 536 | 537 | #: src/handlers/order.py 538 | msgid "no_more_orders" 539 | msgstr "" 540 | 541 | #: src/handlers/order.py 542 | msgid "no_previous_orders" 543 | msgstr "" 544 | 545 | #: src/handlers/order.py 546 | msgid "escrow_unavailable" 547 | msgstr "" 548 | 549 | #: src/handlers/order.py 550 | msgid "change_to {currency}" 551 | msgstr "" 552 | 553 | #: src/handlers/order.py 554 | msgid "send_exchange_sum {currency}" 555 | msgstr "" 556 | 557 | #: src/handlers/order.py 558 | msgid "edit_order_error" 559 | msgstr "" 560 | 561 | #: src/handlers/order.py 562 | msgid "send_new_buy_amount" 563 | msgstr "" 564 | 565 | #: src/handlers/order.py 566 | msgid "send_new_sell_amount" 567 | msgstr "" 568 | 569 | #: src/handlers/order.py 570 | msgid "send_new_payment_system" 571 | msgstr "" 572 | 573 | #: src/handlers/order.py 574 | msgid "send_new_duration {limit}" 575 | msgstr "" 576 | 577 | #: src/handlers/order.py 578 | msgid "repeat_duration_singular {days}" 579 | msgid_plural "repeat_duration_plural {days}" 580 | msgstr[0] "" 581 | msgstr[1] "" 582 | 583 | #: src/handlers/order.py 584 | msgid "send_new_comments" 585 | msgstr "" 586 | 587 | #: src/handlers/order.py 588 | msgid "unarchive_order_error" 589 | msgstr "" 590 | 591 | #: src/handlers/order.py 592 | msgid "archive_order_error" 593 | msgstr "" 594 | 595 | #: src/handlers/order.py 596 | msgid "totally_sure" 597 | msgstr "" 598 | 599 | #: src/handlers/order.py 600 | msgid "delete_order_confirmation" 601 | msgstr "" 602 | 603 | #: src/handlers/order.py 604 | msgid "delete_order_error" 605 | msgstr "" 606 | 607 | #: src/handlers/order.py 608 | msgid "order_deleted" 609 | msgstr "" 610 | 611 | #: src/handlers/order.py 612 | msgid "hide_order_error" 613 | msgstr "" 614 | 615 | #: src/handlers/start_menu.py 616 | msgid "choose_language" 617 | msgstr "" 618 | 619 | #: src/handlers/start_menu.py 620 | msgid "help_message" 621 | msgstr "" 622 | 623 | #: src/handlers/start_menu.py 624 | msgid "exceeded_order_creation_time_limit {orders} {hours}" 625 | msgstr "" 626 | 627 | #: src/handlers/start_menu.py 628 | msgid "choose_your_language" 629 | msgstr "" 630 | 631 | #: src/handlers/start_menu.py 632 | msgid "request_question" 633 | msgstr "" 634 | 635 | #: src/handlers/start_menu.py 636 | msgid "user_not_found" 637 | msgstr "" 638 | 639 | #: src/handlers/start_menu.py 640 | msgid "no_user_argument" 641 | msgstr "" 642 | 643 | #: src/handlers/start_menu.py 644 | msgid "your_subscriptions" 645 | msgstr "" 646 | 647 | #: src/handlers/start_menu.py 648 | msgid "no_subscriptions" 649 | msgstr "" 650 | 651 | #: src/handlers/start_menu.py 652 | msgid "no_currency_argument" 653 | msgstr "" 654 | 655 | #: src/handlers/start_menu.py 656 | msgid "subscription_added" 657 | msgstr "" 658 | 659 | #: src/handlers/start_menu.py 660 | msgid "subscription_exists" 661 | msgstr "" 662 | 663 | #: src/handlers/start_menu.py 664 | msgid "subscription_deleted" 665 | msgstr "" 666 | 667 | #: src/handlers/start_menu.py 668 | msgid "subscription_delete_error" 669 | msgstr "" 670 | 671 | #: src/handlers/support.py 672 | msgid "request_cancelled" 673 | msgstr "" 674 | 675 | #: src/handlers/support.py 676 | msgid "support_response_promise" 677 | msgstr "" 678 | 679 | #: src/handlers/support.py 680 | msgid "reply_error_bot_blocked" 681 | msgstr "" 682 | 683 | #: src/handlers/support.py 684 | msgid "reply_sent" 685 | msgstr "" 686 | 687 | #: src/handlers/support.py 688 | msgid "escrow_enabled" 689 | msgstr "" 690 | 691 | #: src/handlers/support.py 692 | msgid "escrow_disabled" 693 | msgstr "" 694 | 695 | #: src/money.py 696 | msgid "send_decimal_number" 697 | msgstr "" 698 | 699 | #: src/money.py 700 | msgid "send_positive_number" 701 | msgstr "" 702 | 703 | #: src/money.py 704 | msgid "exceeded_money_limit {limit}" 705 | msgstr "" 706 | 707 | #: src/money.py 708 | msgid "shortage_money_limit {limit}" 709 | msgstr "" 710 | 711 | #: src/notifications.py 712 | msgid "order_expired" 713 | msgstr "" 714 | 715 | #: src/whitelist.py 716 | msgid "without_gateway" 717 | msgstr "" 718 | -------------------------------------------------------------------------------- /src/escrow/blockchain/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import json 18 | import typing 19 | from abc import ABC 20 | from abc import abstractmethod 21 | from asyncio import create_task 22 | from asyncio import get_running_loop 23 | from decimal import Decimal 24 | from time import time 25 | 26 | from aiogram.types import InlineKeyboardButton 27 | from aiogram.types import InlineKeyboardMarkup 28 | from aiogram.types import ParseMode 29 | from aiogram.utils import markdown 30 | from bson.objectid import ObjectId 31 | 32 | from src.bot import tg 33 | from src.config import config 34 | from src.database import database 35 | from src.i18n import i18n 36 | 37 | 38 | class InsuranceLimits(typing.NamedTuple): 39 | """Maximum amount of insured asset.""" 40 | 41 | #: Limit on sum of a single offer. 42 | single: Decimal 43 | #: Limit on overall sum of offers. 44 | total: Decimal 45 | 46 | 47 | class BaseBlockchain(ABC): 48 | """Abstract class to represent blockchain node client for escrow exchange.""" 49 | 50 | #: Internal name of blockchain referenced in ``config.ESCROW_FILENAME``. 51 | name: str 52 | #: Frozen set of assets supported by blockchain. 53 | assets: typing.FrozenSet[str] = frozenset() 54 | #: Address used by bot. 55 | address: str 56 | #: Template of URL to transaction in blockchain explorer. Should 57 | #: contain ``{}`` which gets replaced with transaction id. 58 | explorer: str = "{}" 59 | 60 | @abstractmethod 61 | async def connect(self) -> None: 62 | """Establish connection with blockchain node.""" 63 | 64 | @abstractmethod 65 | async def get_limits(self, asset: str) -> InsuranceLimits: 66 | """Get maximum amounts of ``asset`` which will be insured during escrow exchange. 67 | 68 | Escrow offer starts only if sum of it doesn't exceed these limits. 69 | """ 70 | 71 | async def check_transaction( 72 | self, 73 | *, 74 | offer_id: ObjectId, 75 | from_address: str, 76 | amount_with_fee: Decimal, 77 | amount_without_fee: Decimal, 78 | asset: str, 79 | memo: str, 80 | transaction_time: float, 81 | ) -> bool: 82 | """Check transaction in history of escrow address. 83 | 84 | :param offer_id: ``_id`` of escrow offer. 85 | :param from_address: Address which sent assets. 86 | :param amount_with_fee: Amount of transferred asset with fee added. 87 | :param amount_without_fee: Amount of transferred asset with fee substracted. 88 | :param asset: Transferred asset. 89 | :param memo: Memo in blockchain transaction. 90 | :param transaction_time: Start of transaction check. 91 | :return: Queue member with timeout handler or None if queue member is timeouted. 92 | """ 93 | 94 | @abstractmethod 95 | async def transfer( 96 | self, to: str, amount: Decimal, asset: str, memo: str = "" 97 | ) -> str: 98 | """Transfer ``asset`` from ``self.address``. 99 | 100 | :param to: Address assets are transferred to. 101 | :param amount: Amount of transferred asset. 102 | :param asset: Transferred asset. 103 | :return: URL to transaction in blockchain explorer. 104 | """ 105 | 106 | @abstractmethod 107 | async def is_block_confirmed( 108 | self, block_num: int, op: typing.Mapping[str, typing.Any] 109 | ) -> bool: 110 | """Check if block # ``block_num`` has ``op`` after confirmation. 111 | 112 | Check block on blockchain-specific conditions to consider it confirmed. 113 | 114 | :param block_num: Number of block to check. 115 | :param op: Operation to check. 116 | """ 117 | 118 | async def close(self): 119 | """Close connection with blockchain node.""" 120 | 121 | @property 122 | def nodes(self) -> typing.List[str]: 123 | """Get list of node URLs.""" 124 | with open(config.ESCROW_FILENAME) as escrow_file: 125 | return json.load(escrow_file)[self.name]["nodes"] 126 | 127 | @property 128 | def wif(self) -> str: 129 | """Get private key encoded to WIF.""" 130 | with open(config.ESCROW_FILENAME) as escrow_file: 131 | return json.load(escrow_file)[self.name]["wif"] 132 | 133 | def trx_url(self, trx_id: str) -> str: 134 | """Get URL on transaction with ID ``trx_id`` on explorer.""" 135 | return self.explorer.format(trx_id) 136 | 137 | async def create_queue(self) -> typing.List[typing.Dict[str, typing.Any]]: 138 | """Create queue from unconfirmed transactions in database.""" 139 | queue: typing.List[typing.Dict[str, typing.Any]] = [] 140 | cursor = database.escrow.find( 141 | { 142 | "escrow": {"$in": list(self.assets)}, 143 | "memo": {"$exists": True}, 144 | "trx_id": {"$exists": False}, 145 | } 146 | ) 147 | async for offer in cursor: 148 | if offer["type"] == "buy": 149 | address = offer["init"]["send_address"] 150 | amount = offer["sum_buy"].to_decimal() 151 | else: 152 | address = offer["counter"]["send_address"] 153 | amount = offer["sum_sell"].to_decimal() 154 | queue_member = { 155 | "offer_id": offer["_id"], 156 | "from_address": address, 157 | "amount_with_fee": offer["sum_fee_up"].to_decimal(), 158 | "amount_without_fee": amount, 159 | "asset": offer[offer["type"]], 160 | "memo": offer["memo"], 161 | "transaction_time": offer["transaction_time"], 162 | } 163 | scheduled_queue_member = await self.schedule_timeout(queue_member) 164 | if scheduled_queue_member: 165 | queue.append(scheduled_queue_member) 166 | return queue 167 | 168 | def get_min_time(self, queue: typing.List[typing.Dict[str, typing.Any]]) -> float: 169 | """Get timestamp of earliest transaction from ``queue``.""" 170 | return min(queue, key=lambda q: q["transaction_time"])["transaction_time"] 171 | 172 | async def schedule_timeout( 173 | self, queue_member: typing.Dict[str, typing.Any] 174 | ) -> typing.Optional[typing.Dict[str, typing.Any]]: 175 | """Schedule timeout of transaction check.""" 176 | timedelta = queue_member["transaction_time"] - time() 177 | delay = timedelta + config.CHECK_TIMEOUT_HOURS * 60 * 60 178 | if delay <= 0: 179 | await self._check_timeout(queue_member["offer_id"]) 180 | return None 181 | loop = get_running_loop() 182 | queue_member["timeout_handler"] = loop.call_later( 183 | delay, self.check_timeout, queue_member["offer_id"] 184 | ) 185 | return queue_member 186 | 187 | def check_timeout(self, offer_id: ObjectId) -> None: 188 | """Start transaction check timeout asynchronously. 189 | 190 | :param offer_id: ``_id`` of escrow offer. 191 | """ 192 | create_task(self._check_timeout(offer_id)) 193 | 194 | async def _check_timeout(self, offer_id: ObjectId) -> None: 195 | """Timeout transaction check.""" 196 | offer = await database.escrow.find_one_and_delete({"_id": offer_id}) 197 | await database.escrow_archive.insert_one(offer) 198 | await tg.send_message( 199 | offer["init"]["id"], 200 | i18n("check_timeout {hours}", locale=offer["init"]["locale"]).format( 201 | hours=config.CHECK_TIMEOUT_HOURS 202 | ), 203 | ) 204 | await tg.send_message( 205 | offer["counter"]["id"], 206 | i18n("check_timeout {hours}", locale=offer["counter"]["locale"]).format( 207 | hours=config.CHECK_TIMEOUT_HOURS 208 | ), 209 | ) 210 | 211 | async def _confirmation_callback( 212 | self, 213 | offer_id: ObjectId, 214 | op: typing.Mapping[str, typing.Any], 215 | trx_id: str, 216 | block_num: int, 217 | ) -> bool: 218 | """Confirm found block with transaction. 219 | 220 | Notify escrow asset sender and check if block is confirmed. 221 | If it is, continue exchange. If it is not, send warning and 222 | update ``transaction_time`` of escrow offer. 223 | 224 | :param offer_id: ``_id`` of escrow offer. 225 | :param op: Operation object to confirm. 226 | :param trx_id: ID of transaction with desired operation. 227 | :param block_num: Number of block to confirm. 228 | :return: True if transaction was confirmed and False otherwise. 229 | """ 230 | offer = await database.escrow.find_one({"_id": offer_id}) 231 | if not offer: 232 | return False 233 | 234 | if offer["type"] == "buy": 235 | new_currency = "sell" 236 | escrow_user = offer["init"] 237 | other_user = offer["counter"] 238 | elif offer["type"] == "sell": 239 | new_currency = "buy" 240 | escrow_user = offer["counter"] 241 | other_user = offer["init"] 242 | 243 | answer = i18n( 244 | "transaction_passed {currency}", locale=escrow_user["locale"] 245 | ).format(currency=offer[new_currency]) 246 | await tg.send_message(escrow_user["id"], answer) 247 | is_confirmed = await create_task(self.is_block_confirmed(block_num, op)) 248 | if is_confirmed: 249 | await database.escrow.update_one( 250 | {"_id": offer["_id"]}, {"$set": {"trx_id": trx_id, "unsent": True}} 251 | ) 252 | keyboard = InlineKeyboardMarkup() 253 | keyboard.add( 254 | InlineKeyboardButton( 255 | i18n("sent", locale=other_user["locale"]), 256 | callback_data="tokens_sent {}".format(offer["_id"]), 257 | ) 258 | ) 259 | answer = markdown.link( 260 | i18n("transaction_confirmed", locale=other_user["locale"]), 261 | self.trx_url(trx_id), 262 | ) 263 | answer += "\n" + i18n( 264 | "send {amount} {currency} {address}", locale=other_user["locale"] 265 | ).format( 266 | amount=offer[f"sum_{new_currency}"], 267 | currency=offer[new_currency], 268 | address=markdown.escape_md(escrow_user["receive_address"]), 269 | ) 270 | answer += "." 271 | await tg.send_message( 272 | other_user["id"], 273 | answer, 274 | reply_markup=keyboard, 275 | parse_mode=ParseMode.MARKDOWN, 276 | ) 277 | return True 278 | 279 | await database.escrow.update_one( 280 | {"_id": offer["_id"]}, {"$set": {"transaction_time": time()}} 281 | ) 282 | answer = i18n("transaction_not_confirmed", locale=escrow_user["locale"]) 283 | answer += " " + i18n("try_again", locale=escrow_user["locale"]) 284 | await tg.send_message(escrow_user["id"], answer) 285 | return False 286 | 287 | async def _refund_callback( 288 | self, 289 | reasons: typing.FrozenSet[str], 290 | offer_id: ObjectId, 291 | op: typing.Mapping[str, typing.Any], 292 | from_address: str, 293 | amount: Decimal, 294 | asset: str, 295 | block_num: int, 296 | ) -> None: 297 | """Refund transaction after confirmation because of mistakes in it. 298 | 299 | :param reasons: Frozen set of mistakes in transaction. 300 | The only allowed elements are ``asset``, ``amount`` and ``memo``. 301 | :param offer_id: ``_id`` of escrow offer. 302 | :param op: Operation object to confirm. 303 | :param from_address: Address which sent assets. 304 | :param amount: Amount of transferred asset. 305 | :param asset: Transferred asset. 306 | """ 307 | offer = await database.escrow.find_one({"_id": offer_id}) 308 | if not offer: 309 | return 310 | 311 | user = offer["init"] if offer["type"] == "buy" else offer["counter"] 312 | answer = i18n("transfer_mistakes", locale=user["locale"]) 313 | points = [] 314 | for reason in reasons: 315 | if reason == "asset": 316 | memo_point = i18n("wrong_asset", locale="en") 317 | message_point = i18n("wrong_asset", locale=user["locale"]) 318 | elif reason == "amount": 319 | memo_point = i18n("wrong_amount", locale="en") 320 | message_point = i18n("wrong_amount", locale=user["locale"]) 321 | elif reason == "memo": 322 | memo_point = i18n("wrong_memo", locale="en") 323 | message_point = i18n("wrong_memo", locale=user["locale"]) 324 | else: 325 | continue 326 | points.append(memo_point) 327 | answer += f"\n• {message_point}" 328 | 329 | answer += "\n\n" + i18n("refund_promise", locale=user["locale"]) 330 | await tg.send_message(user["id"], answer, parse_mode=ParseMode.MARKDOWN) 331 | is_confirmed = await create_task(self.is_block_confirmed(block_num, op)) 332 | await database.escrow.update_one( 333 | {"_id": offer["_id"]}, {"$set": {"transaction_time": time()}} 334 | ) 335 | if is_confirmed: 336 | trx_url = await self.transfer( 337 | from_address, 338 | amount, 339 | asset, 340 | memo="reason of refund: " + ", ".join(points), 341 | ) 342 | answer = markdown.link( 343 | i18n("transaction_refunded", locale=user["locale"]), trx_url 344 | ) 345 | else: 346 | answer = i18n("transaction_not_confirmed", locale=user["locale"]) 347 | answer += " " + i18n("try_again", locale=user["locale"]) 348 | await tg.send_message(user["id"], answer, parse_mode=ParseMode.MARKDOWN) 349 | 350 | 351 | class StreamBlockchain(BaseBlockchain): 352 | """Blockchain node client supporting continuous stream to check transaction.""" 353 | 354 | _queue: typing.List[typing.Dict[str, typing.Any]] = [] 355 | 356 | def remove_from_queue( 357 | self, offer_id: ObjectId 358 | ) -> typing.Optional[typing.Mapping[str, typing.Any]]: 359 | """Remove transaction with specified ``offer_id`` value from ``self._queue``. 360 | 361 | :param offer_id: ``_id`` of escrow offer. 362 | :return: True if transaction was found and False otherwise. 363 | """ 364 | for queue_member in self._queue: 365 | if queue_member["offer_id"] == offer_id: 366 | if "timeout_handler" in queue_member: 367 | queue_member["timeout_handler"].cancel() 368 | self._queue.remove(queue_member) 369 | return queue_member 370 | return None 371 | 372 | def check_timeout(self, offer_id: ObjectId) -> None: 373 | self.remove_from_queue(offer_id) 374 | super().check_timeout(offer_id) 375 | 376 | @abstractmethod 377 | async def stream(self) -> None: 378 | """Stream new blocks and check if they contain transactions from ``self._queue``. 379 | 380 | Use built-in method to subscribe to new blocks if node has it, 381 | otherwise get new blocks in blockchain-specific time interval between blocks. 382 | 383 | If block contains desired transaction, call ``self._confirmation_callback``. 384 | If it returns True, remove transaction from ``self._queue`` and stop 385 | streaming if ``self._queue`` is empty. 386 | """ 387 | 388 | def start_streaming(self) -> None: 389 | """Start streaming in background asynchronous task.""" 390 | create_task(self.stream()) 391 | 392 | async def add_to_queue(self, **kwargs): 393 | """Add transaction to self._queue to be checked. 394 | 395 | Same parameters as in ``self.check_transaction``. 396 | """ 397 | queue_member = await self.schedule_timeout(kwargs) 398 | if not queue_member: 399 | return 400 | self._queue.append(queue_member) 401 | # Start streaming if not already streaming 402 | if len(self._queue) == 1: 403 | self.start_streaming() 404 | 405 | 406 | class BlockchainConnectionError(Exception): 407 | """Unsuccessful attempt at connection to blockchain node.""" 408 | 409 | 410 | class TransferError(Exception): 411 | """Unsuccessful attempt at transfer.""" 412 | -------------------------------------------------------------------------------- /src/handlers/start_menu.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019, 2020 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | """Handlers for start menu.""" 18 | import re 19 | import typing 20 | from random import SystemRandom 21 | from string import ascii_lowercase 22 | from time import time 23 | 24 | import pymongo 25 | from aiogram import types 26 | from aiogram.dispatcher import FSMContext 27 | from aiogram.dispatcher.filters import Command 28 | from aiogram.dispatcher.filters.state import any_state 29 | from aiogram.types import InlineKeyboardButton 30 | from aiogram.types import InlineKeyboardMarkup 31 | from aiogram.utils.emoji import emojize 32 | from babel import Locale 33 | from babel import parse_locale 34 | from pymongo.errors import DuplicateKeyError 35 | 36 | from src import states 37 | from src import whitelist 38 | from src.bot import dp 39 | from src.bot import tg 40 | from src.config import config 41 | from src.database import database 42 | from src.database import database_user 43 | from src.handlers.base import orders_list 44 | from src.handlers.base import private_handler 45 | from src.handlers.base import start_keyboard 46 | from src.i18n import i18n 47 | from src.money import gateway_currency_regexp 48 | 49 | 50 | def locale_keyboard(): 51 | """Get inline keyboard markup with available locales.""" 52 | keyboard = InlineKeyboardMarkup() 53 | for language in i18n.available_locales: 54 | keyboard.row( 55 | InlineKeyboardButton( 56 | Locale(parse_locale(language)[0]).display_name, 57 | callback_data="locale {}".format(language), 58 | ) 59 | ) 60 | return keyboard 61 | 62 | 63 | @private_handler(commands=["start"], state=any_state) 64 | async def handle_start_command(message: types.Message, state: FSMContext): 65 | """Handle /start. 66 | 67 | Ask for language if user is new or show menu. 68 | """ 69 | user = { 70 | "id": message.from_user.id, 71 | "chat": message.chat.id, 72 | "mention": message.from_user.mention, 73 | "has_username": bool(message.from_user.username), 74 | } 75 | args = message.text.split() 76 | if len(args) > 1: 77 | if args[1][0] == "_": 78 | search_filter = {"mention": "@" + args[1][1:], "has_username": True} 79 | else: 80 | search_filter = {"referral_code": args[1]} 81 | referrer_user = await database.users.find_one(search_filter) 82 | if referrer_user: 83 | user["referrer"] = referrer_user["id"] 84 | if referrer_user.get("referrer"): 85 | user["referrer_of_referrer"] = referrer_user["referrer"] 86 | 87 | result = await database.users.update_one( 88 | {"id": user["id"], "chat": user["chat"]}, {"$setOnInsert": user}, upsert=True 89 | ) 90 | 91 | if not result.matched_count: 92 | await tg.send_message( 93 | message.chat.id, i18n("choose_language"), reply_markup=locale_keyboard() 94 | ) 95 | return 96 | 97 | await state.finish() 98 | await tg.send_message( 99 | message.chat.id, i18n("help_message"), reply_markup=start_keyboard() 100 | ) 101 | 102 | 103 | @dp.callback_query_handler( 104 | lambda call: call.data.startswith("locale "), state=any_state 105 | ) 106 | async def locale_button(call: types.CallbackQuery): 107 | """Choose language from list.""" 108 | locale = call.data.split()[1] 109 | await database.users.update_one( 110 | {"id": call.from_user.id}, {"$set": {"locale": locale}} 111 | ) 112 | i18n.ctx_locale.set(locale) 113 | await call.answer() 114 | await tg.send_message( 115 | call.message.chat.id, i18n("help_message"), reply_markup=start_keyboard() 116 | ) 117 | 118 | 119 | @private_handler(commands=["create"], state=any_state) 120 | @private_handler( 121 | lambda msg: msg.text.startswith(emojize(":heavy_plus_sign:")), state=any_state 122 | ) 123 | async def handle_create(message: types.Message, state: FSMContext): 124 | """Start order creation by asking user for currency they want to buy.""" 125 | current_time = time() 126 | user_orders = await database.orders.count_documents( 127 | { 128 | "user_id": message.from_user.id, 129 | "start_time": {"$gt": current_time - config.ORDERS_LIMIT_HOURS * 3600}, 130 | } 131 | ) 132 | if user_orders >= config.ORDERS_LIMIT_COUNT: 133 | await tg.send_message( 134 | message.chat.id, 135 | i18n("exceeded_order_creation_time_limit {orders} {hours}").format( 136 | orders=config.ORDERS_LIMIT_COUNT, hours=config.ORDERS_LIMIT_HOURS 137 | ), 138 | ) 139 | return 140 | 141 | creation = {"user_id": message.from_user.id} 142 | await database.creation.find_one_and_replace(creation, creation, upsert=True) 143 | await states.OrderCreation.first() 144 | 145 | await tg.send_message( 146 | message.chat.id, 147 | i18n("ask_buy_currency"), 148 | reply_markup=whitelist.currency_keyboard("buy"), 149 | ) 150 | 151 | 152 | @private_handler(commands=["book"], state=any_state) 153 | @private_handler( 154 | lambda msg: msg.text.startswith(emojize(":closed_book:")), state=any_state 155 | ) 156 | async def handle_book( 157 | message: types.Message, 158 | state: FSMContext, 159 | command: typing.Optional[Command.CommandObj] = None, 160 | ): 161 | r"""Show order book with specified currency pair. 162 | 163 | Currency pair is indicated with one or two space separated 164 | arguments after **/book** in message text. If two arguments are 165 | sent, then first is the currency order's creator wants to sell 166 | and second is the currency order's creator wants to buy. If one 167 | argument is sent, then it's any of the currencies in a pair. 168 | 169 | Any argument can be replaced with \*, which results in searching 170 | pairs with any currency in place of the wildcard. 171 | 172 | Examples: 173 | ============= ================================================= 174 | Command Description 175 | ============= ================================================= 176 | /book BTC USD Show orders that sell BTC and buy USD (BTC → USD) 177 | /book BTC * Show orders that sell BTC and buy any currency 178 | /book * USD Show orders that sell any currency and buy USD 179 | /book BTC Show orders that sell or buy BTC 180 | /book * * Equivalent to /book 181 | ============= ================================================= 182 | 183 | """ 184 | query = { 185 | "$or": [{"archived": {"$exists": False}}, {"archived": False}], 186 | "expiration_time": {"$gt": time()}, 187 | } 188 | 189 | if command is not None: 190 | source = message.text.upper().split() 191 | if len(source) == 2: 192 | currency = source[1] 193 | if currency != "*": 194 | regexp = gateway_currency_regexp(currency) 195 | query = {"$and": [query, {"$or": [{"sell": regexp}, {"buy": regexp}]}]} 196 | elif len(source) >= 3: 197 | sell, buy = source[1], source[2] 198 | if sell != "*": 199 | query["sell"] = gateway_currency_regexp(sell) 200 | if buy != "*": 201 | query["buy"] = gateway_currency_regexp(buy) 202 | 203 | cursor = database.orders.find(query).sort("start_time", pymongo.DESCENDING) 204 | quantity = await database.orders.count_documents(query) 205 | await state.finish() 206 | await orders_list( 207 | cursor, message.chat.id, 0, quantity, "orders", user_id=message.from_user.id 208 | ) 209 | 210 | 211 | @private_handler(commands=["my"], state=any_state) 212 | @private_handler( 213 | lambda msg: msg.text.startswith(emojize(":bust_in_silhouette:")), state=any_state 214 | ) 215 | async def handle_my_orders(message: types.Message, state: FSMContext): 216 | """Show user's orders.""" 217 | query = {"user_id": message.from_user.id} 218 | cursor = database.orders.find(query).sort("start_time", pymongo.DESCENDING) 219 | quantity = await database.orders.count_documents(query) 220 | await state.finish() 221 | await orders_list(cursor, message.chat.id, 0, quantity, "my_orders") 222 | 223 | 224 | @private_handler(commands=["link"], state=any_state) 225 | @private_handler( 226 | lambda msg: msg.text.startswith(emojize(":loudspeaker:")), state=any_state 227 | ) 228 | async def get_referral_link(message: types.Message): 229 | """Send user's referral link and generate if it doesn't exist.""" 230 | user = database_user.get() 231 | code = user.get("referral_code") 232 | if code is None: 233 | while True: 234 | cryptogen = SystemRandom() 235 | code = "".join(cryptogen.choice(ascii_lowercase) for _ in range(7)) 236 | try: 237 | await database.users.update_one( 238 | {"_id": user["_id"]}, {"$set": {"referral_code": code}} 239 | ) 240 | except DuplicateKeyError: 241 | continue 242 | else: 243 | break 244 | me = await tg.me 245 | answer = i18n("referral_share {link}").format( 246 | link=f"https://t.me/{me.username}?start={code}" 247 | ) 248 | if message.from_user.username: 249 | answer += "\n" + i18n("referral_share_alias {link}").format( 250 | link=f"https://t.me/{me.username}?start=_{message.from_user.username}" 251 | ) 252 | await tg.send_message( 253 | message.chat.id, 254 | answer, 255 | disable_web_page_preview=True, 256 | reply_markup=start_keyboard(), 257 | ) 258 | 259 | 260 | @private_handler(commands=["locale"], state=any_state) 261 | @private_handler(lambda msg: msg.text.startswith(emojize(":abcd:")), state=any_state) 262 | async def choose_locale(message: types.Message): 263 | """Show list of languages.""" 264 | await tg.send_message( 265 | message.chat.id, i18n("choose_your_language"), reply_markup=locale_keyboard() 266 | ) 267 | 268 | 269 | @private_handler(commands=["help"], state=any_state) 270 | @private_handler( 271 | lambda msg: msg.text.startswith(emojize(":question:")), state=any_state 272 | ) 273 | async def help_command(message: types.Message): 274 | """Handle request to support.""" 275 | await states.asking_support.set() 276 | await tg.send_message( 277 | message.chat.id, 278 | i18n("request_question"), 279 | reply_markup=InlineKeyboardMarkup( 280 | inline_keyboard=[ 281 | [InlineKeyboardButton(i18n("cancel"), callback_data="unhelp")] 282 | ] 283 | ), 284 | ) 285 | 286 | 287 | @private_handler(commands=["claim"], state=any_state) 288 | @private_handler( 289 | lambda msg: msg.text.startswith(emojize(":moneybag:")), state=any_state 290 | ) 291 | async def claim_cashback(message: types.Message, state: FSMContext): 292 | """Start cashback claiming process by asking currency.""" 293 | documents = database.cashback.aggregate( 294 | [ 295 | {"$match": {"id": message.from_user.id}}, 296 | {"$group": {"_id": "$currency", "amount": {"$sum": "$amount"}}}, 297 | {"$sort": {"_id": pymongo.ASCENDING}}, 298 | ] 299 | ) 300 | keyboard = InlineKeyboardMarkup(row_width=1) 301 | empty = True 302 | async for document in documents: 303 | empty = False 304 | currency = document["_id"] 305 | amount = document["amount"] 306 | keyboard.row( 307 | InlineKeyboardButton( 308 | i18n("claim {amount} {currency}").format( 309 | amount=amount, currency=currency 310 | ), 311 | callback_data=f"claim_currency {currency}", 312 | ) 313 | ) 314 | if empty: 315 | await tg.send_message( 316 | message.chat.id, i18n("no_cashback"), reply_markup=keyboard 317 | ) 318 | else: 319 | await tg.send_message( 320 | message.chat.id, i18n("choose_cashback_currency"), reply_markup=keyboard 321 | ) 322 | 323 | 324 | @private_handler(commands=["creator", "c"], state=any_state) 325 | async def search_by_creator(message: types.Message, state: FSMContext): 326 | """Search orders by creator. 327 | 328 | Creator is indicated with username (with or without @) or user ID 329 | after **/creator** or **/c** in message text. 330 | 331 | In contrast to usernames and user IDs, names aren't unique and 332 | therefore not supported. 333 | """ 334 | query: typing.Dict[str, typing.Any] = { 335 | "$or": [{"archived": {"$exists": False}}, {"archived": False}], 336 | "expiration_time": {"$gt": time()}, 337 | } 338 | source = message.text.split() 339 | try: 340 | creator = source[1] 341 | if creator.isdigit(): 342 | query["user_id"] = int(creator) 343 | else: 344 | mention_regexp = f"^{creator}$" if creator[0] == "@" else f"^@{creator}$" 345 | user = await database.users.find_one( 346 | { 347 | "mention": re.compile(mention_regexp, re.IGNORECASE), 348 | "has_username": True, 349 | } 350 | ) 351 | if user: 352 | query["user_id"] = user["id"] 353 | else: 354 | await tg.send_message(message.chat.id, i18n("user_not_found")) 355 | return 356 | except IndexError: 357 | await tg.send_message( 358 | message.chat.id, 359 | i18n("no_user_argument"), 360 | ) 361 | return 362 | 363 | cursor = database.orders.find(query).sort("start_time", pymongo.DESCENDING) 364 | quantity = await database.orders.count_documents(query) 365 | await state.finish() 366 | await orders_list( 367 | cursor, message.chat.id, 0, quantity, "orders", user_id=message.from_user.id 368 | ) 369 | 370 | 371 | @private_handler(commands=["subscribe", "s"], state=any_state) 372 | @private_handler(commands=["unsubscribe", "u"], state=any_state) 373 | async def subcribe_to_pair( 374 | message: types.Message, 375 | state: FSMContext, 376 | command: Command.CommandObj, 377 | ): 378 | r"""Manage subscription to pairs. 379 | 380 | Currency pair is indicated with two space separated arguments 381 | after **/subscribe** or **/unsubscribe** in message text. First 382 | argument is the currency order's creator wants to sell and second 383 | is the currency order's creator wants to buy. 384 | 385 | Similarly to **/book**, any argument can be replaced with \*, which 386 | results in subscribing to pairs with any currency in place of the 387 | wildcard. 388 | 389 | Without arguments commands show list of user's subscriptions. 390 | """ 391 | source = message.text.upper().split() 392 | 393 | if len(source) == 1: 394 | user = await database.subscriptions.find_one({"id": message.from_user.id}) 395 | sublist = "" 396 | if user: 397 | for i, sub in enumerate(user["subscriptions"]): 398 | sublist += "\n{}. {} → {}".format( 399 | i + 1, 400 | sub["sell"] if sub["sell"] else "*", 401 | sub["buy"] if sub["buy"] else "*", 402 | ) 403 | if sublist: 404 | answer = i18n("your_subscriptions") + sublist 405 | else: 406 | answer = i18n("no_subscriptions") 407 | await tg.send_message(message.chat.id, answer, reply_markup=start_keyboard()) 408 | return 409 | 410 | try: 411 | sell, buy = source[1], source[2] 412 | sub = {"sell": None, "buy": None} 413 | if sell != "*": 414 | sub["sell"] = sell 415 | if buy != "*": 416 | sub["buy"] = buy 417 | except IndexError: 418 | await tg.send_message( 419 | message.chat.id, 420 | i18n("no_currency_argument"), 421 | reply_markup=start_keyboard(), 422 | ) 423 | return 424 | 425 | if command.command[0] == "s": 426 | update_result = await database.subscriptions.update_one( 427 | {"id": message.from_user.id}, 428 | { 429 | "$setOnInsert": {"chat": message.chat.id}, 430 | "$addToSet": {"subscriptions": sub}, 431 | }, 432 | upsert=True, 433 | ) 434 | if not update_result.matched_count or update_result.modified_count: 435 | await tg.send_message( 436 | message.chat.id, 437 | i18n("subscription_added"), 438 | reply_markup=start_keyboard(), 439 | ) 440 | else: 441 | await tg.send_message( 442 | message.chat.id, 443 | i18n("subscription_exists"), 444 | reply_markup=start_keyboard(), 445 | ) 446 | elif command.command[0] == "u": 447 | delete_result = await database.subscriptions.update_one( 448 | {"id": message.from_user.id}, {"$pull": {"subscriptions": sub}} 449 | ) 450 | if delete_result.modified_count: 451 | await tg.send_message( 452 | message.chat.id, 453 | i18n("subscription_deleted"), 454 | reply_markup=start_keyboard(), 455 | ) 456 | else: 457 | await tg.send_message( 458 | message.chat.id, 459 | i18n("subscription_delete_error"), 460 | reply_markup=start_keyboard(), 461 | ) 462 | else: 463 | raise AssertionError(f"Unknown command: {command.command}") 464 | -------------------------------------------------------------------------------- /src/handlers/base.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 alfred richardsn 2 | # 3 | # This file is part of TellerBot. 4 | # 5 | # TellerBot is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # This program is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with TellerBot. If not, see . 17 | import math 18 | import typing 19 | from datetime import datetime 20 | from decimal import Decimal 21 | from time import time 22 | 23 | from aiogram import types 24 | from aiogram.utils import markdown 25 | from aiogram.utils.emoji import emojize 26 | from pymongo.cursor import Cursor 27 | 28 | from src.config import config 29 | from src.escrow import get_escrow_instance 30 | 31 | from src.bot import ( # noqa: F401, noreorder 32 | dp, 33 | private_handler, 34 | state_handler, 35 | state_handlers, 36 | ) 37 | from src.bot import tg 38 | from src.database import database, database_user 39 | from src.i18n import i18n 40 | from src.money import normalize 41 | 42 | 43 | def start_keyboard() -> types.ReplyKeyboardMarkup: 44 | """Create reply keyboard with main menu.""" 45 | keyboard = types.ReplyKeyboardMarkup(resize_keyboard=True, row_width=2) 46 | keyboard.add( 47 | types.KeyboardButton(emojize(":heavy_plus_sign: ") + i18n("create_order")), 48 | types.KeyboardButton(emojize(":bust_in_silhouette: ") + i18n("my_orders")), 49 | types.KeyboardButton(emojize(":closed_book: ") + i18n("order_book")), 50 | types.KeyboardButton(emojize(":loudspeaker: ") + i18n("referral_link")), 51 | types.KeyboardButton(emojize(":moneybag: ") + i18n("claim_cashback")), 52 | types.KeyboardButton(emojize(":abcd: ") + i18n("language")), 53 | types.KeyboardButton(emojize(":question: ") + i18n("support")), 54 | ) 55 | return keyboard 56 | 57 | 58 | async def inline_control_buttons( 59 | back: bool = True, skip: bool = True, cancel: bool = True 60 | ) -> typing.List[types.InlineKeyboardButton]: 61 | """Create inline button row with translated labels to control current state.""" 62 | buttons = [] 63 | if back or skip: 64 | row = [] 65 | state_name = await dp.current_state().get_state() 66 | if back: 67 | row.append( 68 | types.InlineKeyboardButton( 69 | i18n("back"), callback_data=f"state {state_name} back" 70 | ) 71 | ) 72 | if skip: 73 | row.append( 74 | types.InlineKeyboardButton( 75 | i18n("skip"), callback_data=f"state {state_name} skip" 76 | ) 77 | ) 78 | buttons.append(row) 79 | if cancel: 80 | buttons.append( 81 | [types.InlineKeyboardButton(i18n("cancel"), callback_data="cancel")] 82 | ) 83 | return buttons 84 | 85 | 86 | async def orders_list( 87 | cursor: Cursor, 88 | chat_id: int, 89 | start: int, 90 | quantity: int, 91 | buttons_data: str, 92 | user_id: typing.Optional[int] = None, 93 | message_id: typing.Optional[int] = None, 94 | invert: typing.Optional[bool] = None, 95 | ) -> None: 96 | """Send list of orders. 97 | 98 | :param cursor: Cursor of MongoDB query to orders. 99 | :param chat_id: Telegram ID of current chat. 100 | :param start: Start index. 101 | :param quantity: Quantity of orders in cursor. 102 | :param buttons_data: Beginning of callback data of left/right buttons. 103 | :param user_id: Telegram ID of current user if cursor is not user-specific. 104 | :param message_id: Telegram ID of message to edit. 105 | :param invert: Invert all prices. 106 | """ 107 | user = database_user.get() 108 | if invert is None: 109 | invert = user.get("invert_book", False) 110 | else: 111 | await database.users.update_one( 112 | {"_id": user["_id"]}, {"$set": {"invert_book": invert}} 113 | ) 114 | 115 | keyboard = types.InlineKeyboardMarkup(row_width=min(config.ORDERS_COUNT // 2, 8)) 116 | 117 | inline_orders_buttons = ( 118 | types.InlineKeyboardButton( 119 | emojize(":arrow_left:"), 120 | callback_data="{} {} {}".format( 121 | buttons_data, start - config.ORDERS_COUNT, 1 if invert else 0 122 | ), 123 | ), 124 | types.InlineKeyboardButton( 125 | emojize(":arrow_right:"), 126 | callback_data="{} {} {}".format( 127 | buttons_data, start + config.ORDERS_COUNT, 1 if invert else 0 128 | ), 129 | ), 130 | ) 131 | 132 | if quantity == 0: 133 | keyboard.row(*inline_orders_buttons) 134 | text = i18n("no_orders") 135 | if message_id is None: 136 | await tg.send_message(chat_id, text, reply_markup=keyboard) 137 | else: 138 | await tg.edit_message_text(text, chat_id, message_id, reply_markup=keyboard) 139 | return 140 | 141 | all_orders = await cursor.to_list(length=start + config.ORDERS_COUNT) 142 | orders = all_orders[start:] 143 | 144 | lines = [] 145 | buttons = [] 146 | current_time = time() 147 | for i, order in enumerate(orders): 148 | line = "" 149 | 150 | if user_id is None: 151 | if not order.get("archived") and order["expiration_time"] > current_time: 152 | line += emojize(":arrow_forward: ") 153 | else: 154 | line += emojize(":pause_button: ") 155 | 156 | exp = Decimal("1e-5") 157 | 158 | if "sum_sell" in order: 159 | line += "{:,} ".format(normalize(order["sum_sell"].to_decimal(), exp)) 160 | line += "{} → ".format(order["sell"]) 161 | 162 | if "sum_buy" in order: 163 | line += "{:,} ".format(normalize(order["sum_buy"].to_decimal(), exp)) 164 | line += order["buy"] 165 | 166 | if "price_sell" in order: 167 | if invert: 168 | line += " ({:,} {}/{})".format( 169 | normalize(order["price_buy"].to_decimal(), exp), 170 | order["buy"], 171 | order["sell"], 172 | ) 173 | else: 174 | line += " ({:,} {}/{})".format( 175 | normalize(order["price_sell"].to_decimal(), exp), 176 | order["sell"], 177 | order["buy"], 178 | ) 179 | 180 | if user_id is not None and order["user_id"] == user_id: 181 | line = f"*{line}*" 182 | 183 | lines.append(f"{i + 1}. {line}") 184 | buttons.append( 185 | types.InlineKeyboardButton( 186 | "{}".format(i + 1), callback_data="get_order {}".format(order["_id"]) 187 | ) 188 | ) 189 | 190 | keyboard.row( 191 | types.InlineKeyboardButton( 192 | i18n("invert"), 193 | callback_data="{} {} {}".format(buttons_data, start, int(not invert)), 194 | ) 195 | ) 196 | keyboard.add(*buttons) 197 | keyboard.row(*inline_orders_buttons) 198 | 199 | text = ( 200 | "\\[" 201 | + i18n("page {number} {total}").format( 202 | number=math.ceil(start / config.ORDERS_COUNT) + 1, 203 | total=math.ceil(quantity / config.ORDERS_COUNT), 204 | ) 205 | + "]\n" 206 | + "\n".join(lines) 207 | ) 208 | 209 | if message_id is None: 210 | await tg.send_message( 211 | chat_id, 212 | text, 213 | reply_markup=keyboard, 214 | parse_mode=types.ParseMode.MARKDOWN, 215 | disable_web_page_preview=True, 216 | ) 217 | else: 218 | await tg.edit_message_text( 219 | text, 220 | chat_id, 221 | message_id, 222 | reply_markup=keyboard, 223 | parse_mode=types.ParseMode.MARKDOWN, 224 | disable_web_page_preview=True, 225 | ) 226 | 227 | 228 | async def show_order( 229 | order: typing.Mapping[str, typing.Any], 230 | chat_id: int, 231 | user_id: int, 232 | message_id: typing.Optional[int] = None, 233 | location_message_id: typing.Optional[int] = None, 234 | show_id: bool = False, 235 | invert: typing.Optional[bool] = None, 236 | edit: bool = False, 237 | locale: typing.Optional[str] = None, 238 | ): 239 | """Send detailed order. 240 | 241 | :param order: Order document. 242 | :param chat_id: Telegram ID of chat to send message to. 243 | :param user_id: Telegram user ID of message receiver. 244 | :param message_id: Telegram ID of message to edit. 245 | :param location_message_id: Telegram ID of message with location object. 246 | It is deleted when **Hide** inline button is pressed. 247 | :param show_id: Add ID of order to the top. 248 | :param invert: Invert price. 249 | :param edit: Enter edit mode. 250 | :param locale: Locale of message receiver. 251 | """ 252 | if locale is None: 253 | locale = i18n.ctx_locale.get() 254 | 255 | new_edit_msg = None 256 | if invert is None: 257 | try: 258 | user = database_user.get() 259 | except LookupError: 260 | user = await database.users.find_one({"id": user_id}) 261 | invert = user.get("invert_order", False) 262 | else: 263 | user = await database.users.find_one_and_update( 264 | {"id": user_id}, {"$set": {"invert_order": invert}} 265 | ) 266 | if "edit" in user: 267 | if edit: 268 | if user["edit"]["field"] == "price": 269 | new_edit_msg = i18n( 270 | "new_price {of_currency} {per_currency}", locale=locale 271 | ) 272 | if invert: 273 | new_edit_msg = new_edit_msg.format( 274 | of_currency=order["buy"], per_currency=order["sell"] 275 | ) 276 | else: 277 | new_edit_msg = new_edit_msg.format( 278 | of_currency=order["sell"], per_currency=order["buy"] 279 | ) 280 | elif user["edit"]["order_message_id"] == message_id: 281 | await tg.delete_message(user["chat"], user["edit"]["message_id"]) 282 | await database.users.update_one( 283 | {"_id": user["_id"]}, {"$unset": {"edit": True, "state": True}} 284 | ) 285 | 286 | if location_message_id is None: 287 | if order.get("lat") is not None and order.get("lon") is not None: 288 | location_message = await tg.send_location( 289 | chat_id, order["lat"], order["lon"] 290 | ) 291 | location_message_id = location_message.message_id 292 | else: 293 | location_message_id = -1 294 | 295 | header = "" 296 | if show_id: 297 | header += "ID: {}\n".format(markdown.code(order["_id"])) 298 | 299 | if order.get("archived"): 300 | header += markdown.bold(i18n("archived", locale=locale)) + "\n" 301 | 302 | creator = await database.users.find_one({"id": order["user_id"]}) 303 | header += "{} ({}) ".format( 304 | markdown.link(creator["mention"], types.User(id=creator["id"]).url), 305 | markdown.code(creator["id"]), 306 | ) 307 | if invert: 308 | act = i18n("sells {sell_currency} {buy_currency}", locale=locale) 309 | else: 310 | act = i18n("buys {buy_currency} {sell_currency}", locale=locale) 311 | header += act.format(buy_currency=order["buy"], sell_currency=order["sell"]) + "\n" 312 | 313 | lines = [header] 314 | field_names = { 315 | "sum_buy": i18n("buy_amount", locale=locale), 316 | "sum_sell": i18n("sell_amount", locale=locale), 317 | "price": i18n("price", locale=locale), 318 | "payment_system": i18n("payment_system", locale=locale), 319 | "duration": i18n("duration", locale=locale), 320 | "comments": i18n("comments", locale=locale), 321 | } 322 | lines_format: typing.Dict[str, typing.Optional[str]] = {} 323 | for name in field_names: 324 | lines_format[name] = None 325 | 326 | if "sum_buy" in order: 327 | lines_format["sum_buy"] = "{} {}".format(order["sum_buy"], order["buy"]) 328 | if "sum_sell" in order: 329 | lines_format["sum_sell"] = "{} {}".format(order["sum_sell"], order["sell"]) 330 | if "price_sell" in order: 331 | if invert: 332 | lines_format["price"] = "{} {}/{}".format( 333 | order["price_buy"], order["buy"], order["sell"] 334 | ) 335 | else: 336 | lines_format["price"] = "{} {}/{}".format( 337 | order["price_sell"], order["sell"], order["buy"] 338 | ) 339 | if "payment_system" in order: 340 | lines_format["payment_system"] = order["payment_system"] 341 | if "duration" in order: 342 | lines_format["duration"] = "{} - {}".format( 343 | datetime.utcfromtimestamp(order["start_time"]).strftime("%d.%m.%Y"), 344 | datetime.utcfromtimestamp(order["expiration_time"]).strftime("%d.%m.%Y"), 345 | ) 346 | if "comments" in order: 347 | lines_format["comments"] = "«{}»".format(order["comments"]) 348 | 349 | keyboard = types.InlineKeyboardMarkup(row_width=6) 350 | 351 | keyboard.row( 352 | types.InlineKeyboardButton( 353 | i18n("invert", locale=locale), 354 | callback_data="{} {} {} {}".format( 355 | "revert" if invert else "invert", 356 | order["_id"], 357 | location_message_id, 358 | int(edit), 359 | ), 360 | ) 361 | ) 362 | 363 | if edit and creator["id"] == user_id: 364 | buttons = [] 365 | for i, (field, value) in enumerate(lines_format.items()): 366 | if value is not None: 367 | lines.append(f"{i + 1}. {field_names[field]} {value}") 368 | else: 369 | lines.append(f"{i + 1}. {field_names[field]} -") 370 | buttons.append( 371 | types.InlineKeyboardButton( 372 | f"{i + 1}", 373 | callback_data="edit {} {} {} 0".format( 374 | order["_id"], field, location_message_id 375 | ), 376 | ) 377 | ) 378 | 379 | keyboard.add(*buttons) 380 | keyboard.row( 381 | types.InlineKeyboardButton( 382 | i18n("finish", locale=locale), 383 | callback_data="{} {} {} 0".format( 384 | "invert" if invert else "revert", order["_id"], location_message_id 385 | ), 386 | ) 387 | ) 388 | 389 | else: 390 | for field, value in lines_format.items(): 391 | if value is not None: 392 | lines.append(field_names[field] + " " + value) 393 | 394 | keyboard.row( 395 | types.InlineKeyboardButton( 396 | i18n("similar", locale=locale), 397 | callback_data="similar {}".format(order["_id"]), 398 | ), 399 | types.InlineKeyboardButton( 400 | i18n("match", locale=locale), 401 | callback_data="match {}".format(order["_id"]), 402 | ), 403 | ) 404 | 405 | if creator["id"] == user_id: 406 | keyboard.row( 407 | types.InlineKeyboardButton( 408 | i18n("edit", locale=locale), 409 | callback_data="{} {} {} 1".format( 410 | "invert" if invert else "revert", 411 | order["_id"], 412 | location_message_id, 413 | ), 414 | ), 415 | types.InlineKeyboardButton( 416 | i18n("delete", locale=locale), 417 | callback_data="delete {} {}".format( 418 | order["_id"], location_message_id 419 | ), 420 | ), 421 | ) 422 | keyboard.row( 423 | types.InlineKeyboardButton( 424 | i18n("unarchive", locale=locale) 425 | if order.get("archived") 426 | else i18n("archive", locale=locale), 427 | callback_data="archive {} {}".format( 428 | order["_id"], location_message_id 429 | ), 430 | ), 431 | types.InlineKeyboardButton( 432 | i18n("change_duration", locale=locale), 433 | callback_data="edit {} duration {} 1".format( 434 | order["_id"], location_message_id 435 | ), 436 | ), 437 | ) 438 | elif "price_sell" in order and not order.get("archived"): 439 | if ( 440 | get_escrow_instance(order["buy"]) is not None 441 | or get_escrow_instance(order["sell"]) is not None 442 | ): 443 | keyboard.row( 444 | types.InlineKeyboardButton( 445 | i18n("escrow", locale=locale), 446 | callback_data="escrow {} sum_buy 0".format(order["_id"]), 447 | ) 448 | ) 449 | 450 | keyboard.row( 451 | types.InlineKeyboardButton( 452 | i18n("hide", locale=locale), 453 | callback_data="hide {}".format(location_message_id), 454 | ) 455 | ) 456 | 457 | answer = "\n".join(lines) 458 | 459 | if message_id is not None: 460 | await tg.edit_message_text( 461 | answer, 462 | chat_id, 463 | message_id, 464 | reply_markup=keyboard, 465 | parse_mode=types.ParseMode.MARKDOWN, 466 | disable_web_page_preview=True, 467 | ) 468 | if new_edit_msg is not None: 469 | keyboard = types.InlineKeyboardMarkup() 470 | keyboard.row( 471 | types.InlineKeyboardButton( 472 | i18n("unset", locale=locale), callback_data="unset" 473 | ) 474 | ) 475 | await tg.edit_message_text( 476 | new_edit_msg, 477 | chat_id, 478 | user["edit"]["message_id"], 479 | reply_markup=keyboard, 480 | ) 481 | else: 482 | await tg.send_message( 483 | chat_id, 484 | answer, 485 | reply_markup=keyboard, 486 | parse_mode=types.ParseMode.MARKDOWN, 487 | disable_web_page_preview=True, 488 | ) 489 | -------------------------------------------------------------------------------- /locale/fr/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # English translations for TellerBot. 2 | # Copyright (C) 2019 Fincubator 3 | # This file is distributed under the same license as the TellerBot project. 4 | # alfred richardsn , 2019. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: TellerBot\n" 9 | "Report-Msgid-Bugs-To: rchrdsn@protonmail.ch\n" 10 | "POT-Creation-Date: 2020-08-25 07:27+0300\n" 11 | "PO-Revision-Date: 2020-06-25 01:41+0000\n" 12 | "Last-Translator: J. Lavoie \n" 13 | "Language: fr\n" 14 | "Language-Team: French " 15 | "\n" 16 | "Plural-Forms: nplurals=2; plural=n > 1\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.8.0\n" 21 | 22 | #: src/escrow/blockchain/__init__.py 23 | msgid "check_timeout {hours}" 24 | msgstr "" 25 | "Le contrôle des transactions a duré plus de {heures} heures. " 26 | "L'entiercement a donc été annulé." 27 | 28 | #: src/escrow/blockchain/__init__.py 29 | msgid "transaction_passed {currency}" 30 | msgstr "La transaction est passée. Je vous notifierai si vous obtenez {currency}." 31 | 32 | #: src/escrow/blockchain/__init__.py src/handlers/escrow.py 33 | msgid "sent" 34 | msgstr "Envoyé" 35 | 36 | #: src/escrow/blockchain/__init__.py 37 | msgid "transaction_confirmed" 38 | msgstr "La transaction est confirmée." 39 | 40 | #: src/escrow/blockchain/__init__.py src/handlers/escrow.py 41 | msgid "send {amount} {currency} {address}" 42 | msgstr "Envoyer {amount} {currency} à l'adresse {address}" 43 | 44 | #: src/escrow/blockchain/__init__.py 45 | msgid "transaction_not_confirmed" 46 | msgstr "La transaction n'est pas confirmée." 47 | 48 | #: src/escrow/blockchain/__init__.py 49 | msgid "try_again" 50 | msgstr "Veuillez réessayer." 51 | 52 | #: src/escrow/blockchain/__init__.py 53 | msgid "transfer_mistakes" 54 | msgstr "Il y a des erreurs dans votre virement :" 55 | 56 | #: src/escrow/blockchain/__init__.py 57 | msgid "wrong_asset" 58 | msgstr "mauvais actif" 59 | 60 | #: src/escrow/blockchain/__init__.py 61 | msgid "wrong_amount" 62 | msgstr "mauvaise somme" 63 | 64 | #: src/escrow/blockchain/__init__.py 65 | msgid "wrong_memo" 66 | msgstr "mauvais mémo" 67 | 68 | #: src/escrow/blockchain/__init__.py 69 | msgid "refund_promise" 70 | msgstr "La transaction sera remboursée après confirmation." 71 | 72 | #: src/escrow/blockchain/__init__.py 73 | msgid "transaction_refunded" 74 | msgstr "La transaction est remboursée." 75 | 76 | #: src/handlers/__init__.py 77 | msgid "unknown_command" 78 | msgstr "Commande inconnue." 79 | 80 | #: src/handlers/__init__.py 81 | msgid "unknown_button" 82 | msgstr "Bouton inconnu." 83 | 84 | #: src/handlers/__init__.py 85 | msgid "unexpected_error" 86 | msgstr "" 87 | "Il y a eu une erreur inattendue lors du traitement de votre demande. Nous" 88 | " sommes déjà informés et nous la réparerons dès que possible !" 89 | 90 | #: src/handlers/base.py 91 | msgid "create_order" 92 | msgstr "Créer une commande" 93 | 94 | #: src/handlers/base.py 95 | msgid "my_orders" 96 | msgstr "Mes commandes" 97 | 98 | #: src/handlers/base.py 99 | msgid "order_book" 100 | msgstr "Journal des commandes" 101 | 102 | #: src/handlers/base.py 103 | msgid "referral_link" 104 | msgstr "" 105 | 106 | #: src/handlers/base.py 107 | msgid "claim_cashback" 108 | msgstr "" 109 | 110 | #: src/handlers/base.py 111 | msgid "language" 112 | msgstr "Langue" 113 | 114 | #: src/handlers/base.py 115 | msgid "support" 116 | msgstr "Assistance" 117 | 118 | #: src/handlers/base.py src/whitelist.py 119 | msgid "back" 120 | msgstr "Retour" 121 | 122 | #: src/handlers/base.py 123 | msgid "skip" 124 | msgstr "Passer" 125 | 126 | #: src/handlers/base.py src/handlers/creation.py src/handlers/escrow.py 127 | #: src/handlers/order.py src/handlers/start_menu.py src/whitelist.py 128 | msgid "cancel" 129 | msgstr "Annuler" 130 | 131 | #: src/handlers/base.py 132 | msgid "no_orders" 133 | msgstr "Il n'y a aucune commande." 134 | 135 | #: src/handlers/base.py src/handlers/creation.py 136 | msgid "invert" 137 | msgstr "Inverser" 138 | 139 | #: src/handlers/base.py 140 | msgid "page {number} {total}" 141 | msgstr "Page {number} sur {total}" 142 | 143 | #: src/handlers/base.py src/handlers/order.py 144 | msgid "new_price {of_currency} {per_currency}" 145 | msgstr "Envoyer le nouveau prix en {of_currency}/{per_currency}." 146 | 147 | #: src/handlers/base.py 148 | msgid "archived" 149 | msgstr "Archivé" 150 | 151 | #: src/handlers/base.py 152 | msgid "sells {sell_currency} {buy_currency}" 153 | msgstr "vend {sell_currency} pour {buy_currency}" 154 | 155 | #: src/handlers/base.py 156 | msgid "buys {buy_currency} {sell_currency}" 157 | msgstr "achète {buy_currency} pour {sell_currency}" 158 | 159 | #: src/handlers/base.py 160 | msgid "buy_amount" 161 | msgstr "Somme des achats :" 162 | 163 | #: src/handlers/base.py 164 | msgid "sell_amount" 165 | msgstr "Somme des ventes :" 166 | 167 | #: src/handlers/base.py 168 | msgid "price" 169 | msgstr "Prix :" 170 | 171 | #: src/handlers/base.py 172 | msgid "payment_system" 173 | msgstr "Système de paiement :" 174 | 175 | #: src/handlers/base.py 176 | msgid "duration" 177 | msgstr "Durée :" 178 | 179 | #: src/handlers/base.py 180 | msgid "comments" 181 | msgstr "Commentaires :" 182 | 183 | #: src/handlers/base.py 184 | msgid "finish" 185 | msgstr "Terminer" 186 | 187 | #: src/handlers/base.py 188 | msgid "similar" 189 | msgstr "Similaire" 190 | 191 | #: src/handlers/base.py 192 | msgid "match" 193 | msgstr "Associer" 194 | 195 | #: src/handlers/base.py 196 | msgid "edit" 197 | msgstr "Modifier" 198 | 199 | #: src/handlers/base.py 200 | msgid "delete" 201 | msgstr "Supprimer" 202 | 203 | #: src/handlers/base.py 204 | msgid "unarchive" 205 | msgstr "Désarchiver" 206 | 207 | #: src/handlers/base.py 208 | msgid "archive" 209 | msgstr "Archiver" 210 | 211 | #: src/handlers/base.py 212 | msgid "change_duration" 213 | msgstr "Prolonger" 214 | 215 | #: src/handlers/base.py 216 | msgid "escrow" 217 | msgstr "Entiercer" 218 | 219 | #: src/handlers/base.py src/handlers/order.py 220 | msgid "hide" 221 | msgstr "Masquer" 222 | 223 | #: src/handlers/base.py src/handlers/order.py 224 | msgid "unset" 225 | msgstr "Désactiver" 226 | 227 | #: src/handlers/cashback.py 228 | msgid "confirm_cashback_address" 229 | msgstr "" 230 | 231 | #: src/handlers/cashback.py 232 | msgid "custom_cashback_address" 233 | msgstr "" 234 | 235 | #: src/handlers/cashback.py 236 | msgid "use_cashback_address {address}" 237 | msgstr "" 238 | 239 | #: src/handlers/cashback.py 240 | msgid "send_cashback_address" 241 | msgstr "" 242 | 243 | #: src/handlers/cashback.py 244 | msgid "claim_transfer_wait" 245 | msgstr "" 246 | 247 | #: src/handlers/cashback.py 248 | msgid "cashback_transfer_error" 249 | msgstr "" 250 | 251 | #: src/handlers/cashback.py 252 | msgid "cashback_transferred" 253 | msgstr "" 254 | 255 | #: src/handlers/creation.py 256 | msgid "wrong_button" 257 | msgstr "Vous utilisez le mauvais bouton." 258 | 259 | #: src/handlers/creation.py 260 | msgid "back_error" 261 | msgstr "Impossible de revenir en arrière." 262 | 263 | #: src/handlers/creation.py 264 | msgid "skip_error" 265 | msgstr "Impossible de passer." 266 | 267 | #: src/handlers/creation.py 268 | msgid "no_creation" 269 | msgstr "Vous ne créez pas de commande." 270 | 271 | #: src/handlers/creation.py 272 | msgid "order_cancelled" 273 | msgstr "La commande est annulée." 274 | 275 | #: src/handlers/creation.py src/handlers/escrow.py src/handlers/order.py 276 | msgid "exceeded_character_limit {limit} {sent}" 277 | msgstr "" 278 | "Cette valeur devrait contenir moins de {limit} caractères (vous avez " 279 | "envoyé {sent} caractères)." 280 | 281 | #: src/handlers/creation.py 282 | msgid "non_latin_characters_gateway" 283 | msgstr "La passerelle peut seulement contenir des caractères latins." 284 | 285 | #: src/handlers/creation.py 286 | msgid "request_whitelisting" 287 | msgstr "Demander une mise sur liste blanche" 288 | 289 | #: src/handlers/creation.py 290 | msgid "gateway_not_whitelisted {currency}" 291 | msgstr "Cette passerelle de {currency} n'est pas sur liste blanche." 292 | 293 | #: src/handlers/creation.py 294 | msgid "non_latin_characters_currency" 295 | msgstr "La devise peut seulement contenir des caractères latins." 296 | 297 | #: src/handlers/creation.py 298 | msgid "no_fiat_gateway" 299 | msgstr "La passerelle ne peut pas être spécifiée pour des monnaies fiduciaires." 300 | 301 | #: src/handlers/creation.py 302 | msgid "choose_gateway {currency}" 303 | msgstr "Choisissez une passerelle de {currency}." 304 | 305 | #: src/handlers/creation.py 306 | msgid "currency_not_whitelisted" 307 | msgstr "Cette devise n'est pas sur liste blanche." 308 | 309 | #: src/handlers/creation.py 310 | msgid "double_request" 311 | msgstr "Vous avez déjà envoyé une demande pour cette devise." 312 | 313 | #: src/handlers/creation.py 314 | msgid "request_sent" 315 | msgstr "Demande envoyée." 316 | 317 | #: src/handlers/creation.py 318 | msgid "ask_sell_currency" 319 | msgstr "Quelle devise voulez-vous vendre ?" 320 | 321 | #: src/handlers/creation.py src/handlers/start_menu.py 322 | msgid "ask_buy_currency" 323 | msgstr "Quelle devise voulez-vous acheter ?" 324 | 325 | #: src/handlers/creation.py 326 | msgid "ask_buy_price {of_currency} {per_currency}" 327 | msgstr "À quel prix (en {of_currency}/{per_currency}) voulez-vous acheter ?" 328 | 329 | #: src/handlers/creation.py 330 | msgid "same_currency_error" 331 | msgstr "Les devises doivent être différentes." 332 | 333 | #: src/handlers/creation.py 334 | msgid "same_gateway_error" 335 | msgstr "Les passerelles doivent être différentes." 336 | 337 | #: src/handlers/creation.py 338 | msgid "ask_sell_price {of_currency} {per_currency}" 339 | msgstr "À quel prix (en {of_currency}/{per_currency}) voulez-vous vendre ?" 340 | 341 | #: src/handlers/creation.py 342 | msgid "ask_sum_currency" 343 | msgstr "Choisissez la devise de la somme de la commande." 344 | 345 | #: src/handlers/creation.py 346 | msgid "ask_order_sum {currency}" 347 | msgstr "Envoyez la somme de la commande en {currency}." 348 | 349 | #: src/handlers/creation.py 350 | msgid "choose_sum_currency_with_buttons" 351 | msgstr "Choisissez la devise de la somme avec les boutons." 352 | 353 | #: src/handlers/creation.py 354 | msgid "ask_location" 355 | msgstr "" 356 | "Envoyez la localisation d'un lieu de RDV préféré pour le paiement en " 357 | "liquide." 358 | 359 | #: src/handlers/creation.py 360 | msgid "cashless_payment_system" 361 | msgstr "Envoyez un système de paiement dématérialisé." 362 | 363 | #: src/handlers/creation.py 364 | msgid "location_not_found" 365 | msgstr "La localisation est introuvable." 366 | 367 | #: src/handlers/creation.py 368 | msgid "ask_duration {limit}" 369 | msgstr "" 370 | 371 | #: src/handlers/creation.py 372 | msgid "choose_location" 373 | msgstr "" 374 | 375 | #: src/handlers/creation.py src/handlers/order.py 376 | msgid "send_natural_number" 377 | msgstr "" 378 | 379 | #: src/handlers/creation.py src/handlers/order.py 380 | msgid "exceeded_duration_limit {limit}" 381 | msgstr "" 382 | 383 | #: src/handlers/creation.py 384 | msgid "ask_comments" 385 | msgstr "Ajoutez des commentaires supplémentaires." 386 | 387 | #: src/handlers/creation.py 388 | msgid "order_set" 389 | msgstr "" 390 | 391 | #: src/handlers/escrow.py 392 | msgid "send_at_least_8_digits" 393 | msgstr "" 394 | 395 | #: src/handlers/escrow.py 396 | msgid "digits_parsing_error" 397 | msgstr "" 398 | 399 | #: src/handlers/escrow.py 400 | msgid "offer_not_active" 401 | msgstr "" 402 | 403 | #: src/handlers/escrow.py 404 | msgid "exceeded_order_sum" 405 | msgstr "" 406 | 407 | #: src/handlers/escrow.py 408 | msgid "continue" 409 | msgstr "Continuer" 410 | 411 | #: src/handlers/escrow.py 412 | msgid "exceeded_insurance {amount} {currency}" 413 | msgstr "" 414 | 415 | #: src/handlers/escrow.py 416 | msgid "exceeded_insurance_options" 417 | msgstr "" 418 | 419 | #: src/handlers/escrow.py 420 | msgid "ask_fee {fee_percents}" 421 | msgstr "" 422 | 423 | #: src/handlers/escrow.py 424 | msgid "will_pay {amount} {currency}" 425 | msgstr "(Vous payerez {amount} {currency})" 426 | 427 | #: src/handlers/escrow.py 428 | msgid "will_get {amount} {currency}" 429 | msgstr "(Vous recevrez {amount} {currency})" 430 | 431 | #: src/handlers/escrow.py 432 | msgid "yes" 433 | msgstr "Oui" 434 | 435 | #: src/handlers/escrow.py src/handlers/order.py 436 | msgid "no" 437 | msgstr "Non" 438 | 439 | #: src/handlers/escrow.py 440 | msgid "escrow_cancelled" 441 | msgstr "L'entiercement a été annulé." 442 | 443 | #: src/handlers/escrow.py 444 | msgid "choose_bank" 445 | msgstr "" 446 | 447 | #: src/handlers/escrow.py 448 | msgid "request_full_card_number {currency} {user}" 449 | msgstr "" 450 | 451 | #: src/handlers/escrow.py 452 | msgid "asked_full_card_number {user}" 453 | msgstr "" 454 | 455 | #: src/handlers/escrow.py 456 | msgid "ask_address {currency}" 457 | msgstr "" 458 | 459 | #: src/handlers/escrow.py 460 | msgid "bank_not_supported" 461 | msgstr "Cette banque n'est pas prise en charge." 462 | 463 | #: src/handlers/escrow.py 464 | msgid "send_first_and_last_4_digits_of_card_number {currency}" 465 | msgstr "" 466 | 467 | #: src/handlers/escrow.py 468 | msgid "wrong_full_card_number_receiver {user}" 469 | msgstr "Vous devriez l'envoyer à {user}, pas à moi !" 470 | 471 | #: src/handlers/escrow.py 472 | msgid "exchange_continued {user}" 473 | msgstr "" 474 | 475 | #: src/handlers/escrow.py 476 | msgid "send_name_patronymic_surname" 477 | msgstr "" 478 | 479 | #: src/handlers/escrow.py 480 | msgid "wrong_word_count {word_count}" 481 | msgstr "" 482 | 483 | #: src/handlers/escrow.py 484 | msgid "show_order" 485 | msgstr "" 486 | 487 | #: src/handlers/escrow.py 488 | msgid "accept" 489 | msgstr "Accepter" 490 | 491 | #: src/handlers/escrow.py 492 | msgid "decline" 493 | msgstr "Décliner" 494 | 495 | #: src/handlers/escrow.py 496 | msgid "" 497 | "escrow_offer_notification {user} {sell_amount} {sell_currency} for " 498 | "{buy_amount} {buy_currency}" 499 | msgstr "" 500 | 501 | #: src/handlers/escrow.py 502 | msgid "using {bank}" 503 | msgstr "" 504 | 505 | #: src/handlers/escrow.py 506 | msgid "offer_sent" 507 | msgstr "Offre envoyée." 508 | 509 | #: src/handlers/escrow.py 510 | msgid "escrow_offer_declined" 511 | msgstr "" 512 | 513 | #: src/handlers/escrow.py 514 | msgid "offer_declined" 515 | msgstr "" 516 | 517 | #: src/handlers/escrow.py 518 | msgid "transaction_check_starting" 519 | msgstr "" 520 | 521 | #: src/handlers/escrow.py 522 | msgid "transaction_not_found" 523 | msgstr "" 524 | 525 | #: src/handlers/escrow.py 526 | msgid "check" 527 | msgstr "" 528 | 529 | #: src/handlers/escrow.py 530 | msgid "with_memo" 531 | msgstr "avec mémo" 532 | 533 | #: src/handlers/escrow.py 534 | msgid "transfer_information_sent" 535 | msgstr "" 536 | 537 | #: src/handlers/escrow.py 538 | msgid "transaction_completion_notification_promise" 539 | msgstr "" 540 | 541 | #: src/handlers/escrow.py 542 | msgid "cancel_after_transfer" 543 | msgstr "" 544 | 545 | #: src/handlers/escrow.py 546 | msgid "cancel_before_verification" 547 | msgstr "" 548 | 549 | #: src/handlers/escrow.py 550 | msgid "transfer_already_confirmed" 551 | msgstr "Vous avez déjà confirmé ce virement." 552 | 553 | #: src/handlers/escrow.py 554 | msgid "receiving_confirmation {currency} {user}" 555 | msgstr "Avez-vous reçu {currency} de {user} ?" 556 | 557 | #: src/handlers/escrow.py 558 | msgid "complete_escrow_promise" 559 | msgstr "Quand votre virement sera confirmé, je complèterai l'entiercement." 560 | 561 | #: src/handlers/escrow.py 562 | msgid "escrow_completing" 563 | msgstr "" 564 | 565 | #: src/handlers/escrow.py 566 | msgid "escrow_completed" 567 | msgstr "L'entiercement est terminé !" 568 | 569 | #: src/handlers/escrow.py 570 | msgid "escrow_sent {amount} {currency}" 571 | msgstr "Je vous ai envoyé {amount} {currency}." 572 | 573 | #: src/handlers/escrow.py 574 | msgid "request_validation_promise" 575 | msgstr "" 576 | 577 | #: src/handlers/order.py 578 | msgid "order_not_found" 579 | msgstr "" 580 | 581 | #: src/handlers/order.py 582 | msgid "no_more_orders" 583 | msgstr "" 584 | 585 | #: src/handlers/order.py 586 | msgid "no_previous_orders" 587 | msgstr "" 588 | 589 | #: src/handlers/order.py 590 | msgid "escrow_unavailable" 591 | msgstr "" 592 | 593 | #: src/handlers/order.py 594 | msgid "escrow_starting_error" 595 | msgstr "" 596 | 597 | #: src/handlers/order.py 598 | msgid "change_to {currency}" 599 | msgstr "" 600 | 601 | #: src/handlers/order.py 602 | msgid "send_exchange_sum {currency}" 603 | msgstr "" 604 | 605 | #: src/handlers/order.py 606 | msgid "edit_order_error" 607 | msgstr "" 608 | 609 | #: src/handlers/order.py 610 | msgid "send_new_buy_amount" 611 | msgstr "" 612 | 613 | #: src/handlers/order.py 614 | msgid "send_new_sell_amount" 615 | msgstr "" 616 | 617 | #: src/handlers/order.py 618 | msgid "send_new_payment_system" 619 | msgstr "" 620 | 621 | #: src/handlers/order.py 622 | msgid "send_new_duration {limit}" 623 | msgstr "" 624 | 625 | #: src/handlers/order.py 626 | msgid "repeat_duration_singular {days}" 627 | msgid_plural "repeat_duration_plural {days}" 628 | msgstr[0] "" 629 | msgstr[1] "" 630 | 631 | #: src/handlers/order.py 632 | msgid "send_new_comments" 633 | msgstr "" 634 | 635 | #: src/handlers/order.py 636 | msgid "unarchive_order_error" 637 | msgstr "" 638 | 639 | #: src/handlers/order.py 640 | msgid "archive_order_error" 641 | msgstr "" 642 | 643 | #: src/handlers/order.py 644 | msgid "totally_sure" 645 | msgstr "" 646 | 647 | #: src/handlers/order.py 648 | msgid "delete_order_confirmation" 649 | msgstr "" 650 | 651 | #: src/handlers/order.py 652 | msgid "delete_order_error" 653 | msgstr "" 654 | 655 | #: src/handlers/order.py 656 | msgid "order_deleted" 657 | msgstr "" 658 | 659 | #: src/handlers/order.py 660 | msgid "hide_order_error" 661 | msgstr "" 662 | 663 | #: src/handlers/start_menu.py 664 | msgid "choose_language" 665 | msgstr "Veuillez choisir votre langue." 666 | 667 | #: src/handlers/start_menu.py 668 | msgid "help_message" 669 | msgstr "" 670 | "Bonjour, je suis TellerBot. Je peux vous aider à rencontrer les des gens " 671 | "avec qui vous pouvez échanger de l'argent.\n" 672 | "\n" 673 | "Choisissez une des options sur votre clavier." 674 | 675 | #: src/handlers/start_menu.py 676 | msgid "exceeded_order_creation_time_limit {orders} {hours}" 677 | msgstr "" 678 | 679 | #: src/handlers/start_menu.py 680 | msgid "referral_share {link}" 681 | msgstr "" 682 | 683 | #: src/handlers/start_menu.py 684 | msgid "referral_share_alias {link}" 685 | msgstr "" 686 | 687 | #: src/handlers/start_menu.py 688 | msgid "choose_your_language" 689 | msgstr "Choisissez votre langue." 690 | 691 | #: src/handlers/start_menu.py 692 | msgid "request_question" 693 | msgstr "Quelle est votre question ?" 694 | 695 | #: src/handlers/start_menu.py 696 | msgid "claim {amount} {currency}" 697 | msgstr "" 698 | 699 | #: src/handlers/start_menu.py 700 | msgid "no_cashback" 701 | msgstr "" 702 | 703 | #: src/handlers/start_menu.py 704 | msgid "choose_cashback_currency" 705 | msgstr "" 706 | 707 | #: src/handlers/start_menu.py 708 | msgid "user_not_found" 709 | msgstr "L'utilisateur est introuvable." 710 | 711 | #: src/handlers/start_menu.py 712 | msgid "no_user_argument" 713 | msgstr "" 714 | 715 | #: src/handlers/start_menu.py 716 | msgid "your_subscriptions" 717 | msgstr "" 718 | 719 | #: src/handlers/start_menu.py 720 | msgid "no_subscriptions" 721 | msgstr "" 722 | 723 | #: src/handlers/start_menu.py 724 | msgid "no_currency_argument" 725 | msgstr "" 726 | 727 | #: src/handlers/start_menu.py 728 | msgid "subscription_added" 729 | msgstr "" 730 | 731 | #: src/handlers/start_menu.py 732 | msgid "subscription_exists" 733 | msgstr "" 734 | 735 | #: src/handlers/start_menu.py 736 | msgid "subscription_deleted" 737 | msgstr "" 738 | 739 | #: src/handlers/start_menu.py 740 | msgid "subscription_delete_error" 741 | msgstr "" 742 | 743 | #: src/handlers/support.py 744 | msgid "request_cancelled" 745 | msgstr "Votre demande est annulée." 746 | 747 | #: src/handlers/support.py 748 | msgid "support_response_promise" 749 | msgstr "Votre message a été transmis. Nous vous répondrons dans les 24 heures." 750 | 751 | #: src/handlers/support.py 752 | msgid "reply_error_bot_blocked" 753 | msgstr "Impossible d'envoyer la réponse, j'ai été bloqué par l'utilisateur." 754 | 755 | #: src/handlers/support.py 756 | msgid "reply_sent" 757 | msgstr "La réponse est envoyée." 758 | 759 | #: src/handlers/support.py 760 | msgid "escrow_enabled" 761 | msgstr "" 762 | 763 | #: src/handlers/support.py 764 | msgid "escrow_disabled" 765 | msgstr "" 766 | 767 | #: src/money.py 768 | msgid "send_decimal_number" 769 | msgstr "" 770 | 771 | #: src/money.py 772 | msgid "send_positive_number" 773 | msgstr "" 774 | 775 | #: src/money.py 776 | msgid "exceeded_money_limit {limit}" 777 | msgstr "" 778 | 779 | #: src/money.py 780 | msgid "shortage_money_limit {limit}" 781 | msgstr "" 782 | 783 | #: src/notifications.py 784 | msgid "order_expired" 785 | msgstr "" 786 | 787 | #: src/whitelist.py 788 | msgid "without_gateway" 789 | msgstr "" 790 | -------------------------------------------------------------------------------- /locale/en/LC_MESSAGES/bot.po: -------------------------------------------------------------------------------- 1 | # English translations for TellerBot. 2 | # Copyright (C) 2019 Fincubator 3 | # This file is distributed under the same license as the TellerBot project. 4 | # alfred richardsn , 2019. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: TellerBot\n" 9 | "Report-Msgid-Bugs-To: rchrdsn@protonmail.ch\n" 10 | "POT-Creation-Date: 2020-08-25 07:27+0300\n" 11 | "PO-Revision-Date: 2020-06-17 12:41+0000\n" 12 | "Last-Translator: alfred richardsn \n" 13 | "Language: en\n" 14 | "Language-Team: English " 15 | "\n" 16 | "Plural-Forms: nplurals=2; plural=n != 1\n" 17 | "MIME-Version: 1.0\n" 18 | "Content-Type: text/plain; charset=utf-8\n" 19 | "Content-Transfer-Encoding: 8bit\n" 20 | "Generated-By: Babel 2.8.0\n" 21 | 22 | #: src/escrow/blockchain/__init__.py 23 | msgid "check_timeout {hours}" 24 | msgstr "Transaction check took longer than {hours} hours, so escrow was cancelled." 25 | 26 | #: src/escrow/blockchain/__init__.py 27 | msgid "transaction_passed {currency}" 28 | msgstr "Transaction has passed. I'll notify should you get {currency}." 29 | 30 | #: src/escrow/blockchain/__init__.py src/handlers/escrow.py 31 | msgid "sent" 32 | msgstr "Sent" 33 | 34 | #: src/escrow/blockchain/__init__.py 35 | msgid "transaction_confirmed" 36 | msgstr "Transaction is confirmed." 37 | 38 | #: src/escrow/blockchain/__init__.py src/handlers/escrow.py 39 | msgid "send {amount} {currency} {address}" 40 | msgstr "Send {amount} {currency} to address {address}" 41 | 42 | #: src/escrow/blockchain/__init__.py 43 | msgid "transaction_not_confirmed" 44 | msgstr "Transaction is not confirmed." 45 | 46 | #: src/escrow/blockchain/__init__.py 47 | msgid "try_again" 48 | msgstr "Please try again." 49 | 50 | #: src/escrow/blockchain/__init__.py 51 | msgid "transfer_mistakes" 52 | msgstr "There are mistakes in your transfer:" 53 | 54 | #: src/escrow/blockchain/__init__.py 55 | msgid "wrong_asset" 56 | msgstr "wrong asset" 57 | 58 | #: src/escrow/blockchain/__init__.py 59 | msgid "wrong_amount" 60 | msgstr "wrong amount" 61 | 62 | #: src/escrow/blockchain/__init__.py 63 | msgid "wrong_memo" 64 | msgstr "wrong memo" 65 | 66 | #: src/escrow/blockchain/__init__.py 67 | msgid "refund_promise" 68 | msgstr "Transaction will be refunded after confirmation." 69 | 70 | #: src/escrow/blockchain/__init__.py 71 | msgid "transaction_refunded" 72 | msgstr "Transaction is refunded." 73 | 74 | #: src/handlers/__init__.py 75 | msgid "unknown_command" 76 | msgstr "Unknown command." 77 | 78 | #: src/handlers/__init__.py 79 | msgid "unknown_button" 80 | msgstr "Unknown button." 81 | 82 | #: src/handlers/__init__.py 83 | msgid "unexpected_error" 84 | msgstr "" 85 | "There was an unexpected error when handling your request. We're already " 86 | "notified and will fix it as soon as possible!" 87 | 88 | #: src/handlers/base.py 89 | msgid "create_order" 90 | msgstr "Create order" 91 | 92 | #: src/handlers/base.py 93 | msgid "my_orders" 94 | msgstr "My orders" 95 | 96 | #: src/handlers/base.py 97 | msgid "order_book" 98 | msgstr "Order book" 99 | 100 | #: src/handlers/base.py 101 | msgid "referral_link" 102 | msgstr "Referral link" 103 | 104 | #: src/handlers/base.py 105 | msgid "claim_cashback" 106 | msgstr "Claim cashback" 107 | 108 | #: src/handlers/base.py 109 | msgid "language" 110 | msgstr "Language" 111 | 112 | #: src/handlers/base.py 113 | msgid "support" 114 | msgstr "Support" 115 | 116 | #: src/handlers/base.py src/whitelist.py 117 | msgid "back" 118 | msgstr "Back" 119 | 120 | #: src/handlers/base.py 121 | msgid "skip" 122 | msgstr "Skip" 123 | 124 | #: src/handlers/base.py src/handlers/creation.py src/handlers/escrow.py 125 | #: src/handlers/order.py src/handlers/start_menu.py src/whitelist.py 126 | msgid "cancel" 127 | msgstr "Cancel" 128 | 129 | #: src/handlers/base.py 130 | msgid "no_orders" 131 | msgstr "There are no orders." 132 | 133 | #: src/handlers/base.py src/handlers/creation.py 134 | msgid "invert" 135 | msgstr "Invert" 136 | 137 | #: src/handlers/base.py 138 | msgid "page {number} {total}" 139 | msgstr "Page {number} of {total}" 140 | 141 | #: src/handlers/base.py src/handlers/order.py 142 | msgid "new_price {of_currency} {per_currency}" 143 | msgstr "Send new price in {of_currency}/{per_currency}." 144 | 145 | #: src/handlers/base.py 146 | msgid "archived" 147 | msgstr "Archived" 148 | 149 | #: src/handlers/base.py 150 | msgid "sells {sell_currency} {buy_currency}" 151 | msgstr "sells {sell_currency} for {buy_currency}" 152 | 153 | #: src/handlers/base.py 154 | msgid "buys {buy_currency} {sell_currency}" 155 | msgstr "buys {buy_currency} for {sell_currency}" 156 | 157 | #: src/handlers/base.py 158 | msgid "buy_amount" 159 | msgstr "Amount of buying:" 160 | 161 | #: src/handlers/base.py 162 | msgid "sell_amount" 163 | msgstr "Amount of selling:" 164 | 165 | #: src/handlers/base.py 166 | msgid "price" 167 | msgstr "Price:" 168 | 169 | #: src/handlers/base.py 170 | msgid "payment_system" 171 | msgstr "Payment system:" 172 | 173 | #: src/handlers/base.py 174 | msgid "duration" 175 | msgstr "Duration:" 176 | 177 | #: src/handlers/base.py 178 | msgid "comments" 179 | msgstr "Comments:" 180 | 181 | #: src/handlers/base.py 182 | msgid "finish" 183 | msgstr "Finish" 184 | 185 | #: src/handlers/base.py 186 | msgid "similar" 187 | msgstr "Similar" 188 | 189 | #: src/handlers/base.py 190 | msgid "match" 191 | msgstr "Match" 192 | 193 | #: src/handlers/base.py 194 | msgid "edit" 195 | msgstr "Edit" 196 | 197 | #: src/handlers/base.py 198 | msgid "delete" 199 | msgstr "Delete" 200 | 201 | #: src/handlers/base.py 202 | msgid "unarchive" 203 | msgstr "Unarchive" 204 | 205 | #: src/handlers/base.py 206 | msgid "archive" 207 | msgstr "Archive" 208 | 209 | #: src/handlers/base.py 210 | msgid "change_duration" 211 | msgstr "Prolong" 212 | 213 | #: src/handlers/base.py 214 | msgid "escrow" 215 | msgstr "Escrow" 216 | 217 | #: src/handlers/base.py src/handlers/order.py 218 | msgid "hide" 219 | msgstr "Hide" 220 | 221 | #: src/handlers/base.py src/handlers/order.py 222 | msgid "unset" 223 | msgstr "Unset" 224 | 225 | #: src/handlers/cashback.py 226 | msgid "confirm_cashback_address" 227 | msgstr "Yes" 228 | 229 | #: src/handlers/cashback.py 230 | msgid "custom_cashback_address" 231 | msgstr "No, use other address" 232 | 233 | #: src/handlers/cashback.py 234 | msgid "use_cashback_address {address}" 235 | msgstr "Should cashback be sent on address {address}?" 236 | 237 | #: src/handlers/cashback.py 238 | msgid "send_cashback_address" 239 | msgstr "Send address where cashback will be sent." 240 | 241 | #: src/handlers/cashback.py 242 | msgid "claim_transfer_wait" 243 | msgstr "Transferring cashback..." 244 | 245 | #: src/handlers/cashback.py 246 | msgid "cashback_transfer_error" 247 | msgstr "" 248 | "Couldn't transfer cashback. Make sure that you're claiming a cashback " 249 | "with a transferable amount." 250 | 251 | #: src/handlers/cashback.py 252 | msgid "cashback_transferred" 253 | msgstr "Cashback transferred!" 254 | 255 | #: src/handlers/creation.py 256 | msgid "wrong_button" 257 | msgstr "You're using the wrong button." 258 | 259 | #: src/handlers/creation.py 260 | msgid "back_error" 261 | msgstr "Couldn't go back." 262 | 263 | #: src/handlers/creation.py 264 | msgid "skip_error" 265 | msgstr "Couldn't skip." 266 | 267 | #: src/handlers/creation.py 268 | msgid "no_creation" 269 | msgstr "You are not creating order." 270 | 271 | #: src/handlers/creation.py 272 | msgid "order_cancelled" 273 | msgstr "Order is cancelled." 274 | 275 | #: src/handlers/creation.py src/handlers/escrow.py src/handlers/order.py 276 | msgid "exceeded_character_limit {limit} {sent}" 277 | msgstr "" 278 | "This value should contain less than {limit} characters (you sent {sent} " 279 | "characters)." 280 | 281 | #: src/handlers/creation.py 282 | msgid "non_latin_characters_gateway" 283 | msgstr "Gateway may only contain latin characters." 284 | 285 | #: src/handlers/creation.py 286 | msgid "request_whitelisting" 287 | msgstr "Request whitelisting" 288 | 289 | #: src/handlers/creation.py 290 | msgid "gateway_not_whitelisted {currency}" 291 | msgstr "This gateway of {currency} is not whitelisted." 292 | 293 | #: src/handlers/creation.py 294 | msgid "non_latin_characters_currency" 295 | msgstr "Currency may only contain latin characters." 296 | 297 | #: src/handlers/creation.py 298 | msgid "no_fiat_gateway" 299 | msgstr "Gateway can't be specified for fiat currencies." 300 | 301 | #: src/handlers/creation.py 302 | msgid "choose_gateway {currency}" 303 | msgstr "Choose gateway of {currency}." 304 | 305 | #: src/handlers/creation.py 306 | msgid "currency_not_whitelisted" 307 | msgstr "This currency is not whitelisted." 308 | 309 | #: src/handlers/creation.py 310 | msgid "double_request" 311 | msgstr "You've already sent request for this currency." 312 | 313 | #: src/handlers/creation.py 314 | msgid "request_sent" 315 | msgstr "Request sent." 316 | 317 | #: src/handlers/creation.py 318 | msgid "ask_sell_currency" 319 | msgstr "What currency do you want to sell?" 320 | 321 | #: src/handlers/creation.py src/handlers/start_menu.py 322 | msgid "ask_buy_currency" 323 | msgstr "What currency do you want to buy?" 324 | 325 | #: src/handlers/creation.py 326 | msgid "ask_buy_price {of_currency} {per_currency}" 327 | msgstr "At what price (in {of_currency}/{per_currency}) do you want to buy?" 328 | 329 | #: src/handlers/creation.py 330 | msgid "same_currency_error" 331 | msgstr "Currencies should be different." 332 | 333 | #: src/handlers/creation.py 334 | msgid "same_gateway_error" 335 | msgstr "Gateways should be different." 336 | 337 | #: src/handlers/creation.py 338 | msgid "ask_sell_price {of_currency} {per_currency}" 339 | msgstr "At what price (in {of_currency}/{per_currency}) do you want to sell?" 340 | 341 | #: src/handlers/creation.py 342 | msgid "ask_sum_currency" 343 | msgstr "Choose currency of order sum." 344 | 345 | #: src/handlers/creation.py 346 | msgid "ask_order_sum {currency}" 347 | msgstr "Send order sum in {currency}." 348 | 349 | #: src/handlers/creation.py 350 | msgid "choose_sum_currency_with_buttons" 351 | msgstr "Choose currency of sum with buttons." 352 | 353 | #: src/handlers/creation.py 354 | msgid "ask_location" 355 | msgstr "Send location of a preferred meeting point for cash payment." 356 | 357 | #: src/handlers/creation.py 358 | msgid "cashless_payment_system" 359 | msgstr "Send cashless payment system." 360 | 361 | #: src/handlers/creation.py 362 | msgid "location_not_found" 363 | msgstr "Location is not found." 364 | 365 | #: src/handlers/creation.py 366 | msgid "ask_duration {limit}" 367 | msgstr "Send duration of order up to {limit} days." 368 | 369 | #: src/handlers/creation.py 370 | msgid "choose_location" 371 | msgstr "Choose one of these locations:" 372 | 373 | #: src/handlers/creation.py src/handlers/order.py 374 | msgid "send_natural_number" 375 | msgstr "Send natural number." 376 | 377 | #: src/handlers/creation.py src/handlers/order.py 378 | msgid "exceeded_duration_limit {limit}" 379 | msgstr "Send number less than {limit}." 380 | 381 | #: src/handlers/creation.py 382 | msgid "ask_comments" 383 | msgstr "Add any additional comments." 384 | 385 | #: src/handlers/creation.py 386 | msgid "order_set" 387 | msgstr "Order is set." 388 | 389 | #: src/handlers/escrow.py 390 | msgid "send_at_least_8_digits" 391 | msgstr "You should send at least 8 digits." 392 | 393 | #: src/handlers/escrow.py 394 | msgid "digits_parsing_error" 395 | msgstr "Can't get digits from message." 396 | 397 | #: src/handlers/escrow.py 398 | msgid "offer_not_active" 399 | msgstr "Offer is not active." 400 | 401 | #: src/handlers/escrow.py 402 | msgid "exceeded_order_sum" 403 | msgstr "Send number not exceeding order's sum." 404 | 405 | #: src/handlers/escrow.py 406 | msgid "continue" 407 | msgstr "Continue" 408 | 409 | #: src/handlers/escrow.py 410 | msgid "exceeded_insurance {amount} {currency}" 411 | msgstr "" 412 | "Escrow asset sum exceeds maximum amount to be insured. If you continue, " 413 | "only {amount} {currency} will be protected and refunded in case of " 414 | "unexpected events during the exchange." 415 | 416 | #: src/handlers/escrow.py 417 | msgid "exceeded_insurance_options" 418 | msgstr "" 419 | "You can send a smaller number, continue with partial insurance or cancel " 420 | "offer." 421 | 422 | #: src/handlers/escrow.py 423 | msgid "ask_fee {fee_percents}" 424 | msgstr "Do you agree to pay a fee of {fee_percents}%?" 425 | 426 | #: src/handlers/escrow.py 427 | msgid "will_pay {amount} {currency}" 428 | msgstr "(You'll pay {amount} {currency})" 429 | 430 | #: src/handlers/escrow.py 431 | msgid "will_get {amount} {currency}" 432 | msgstr "(You'll get {amount} {currency})" 433 | 434 | #: src/handlers/escrow.py 435 | msgid "yes" 436 | msgstr "Yes" 437 | 438 | #: src/handlers/escrow.py src/handlers/order.py 439 | msgid "no" 440 | msgstr "No" 441 | 442 | #: src/handlers/escrow.py 443 | msgid "escrow_cancelled" 444 | msgstr "Escrow was cancelled." 445 | 446 | #: src/handlers/escrow.py 447 | msgid "choose_bank" 448 | msgstr "Choose bank." 449 | 450 | #: src/handlers/escrow.py 451 | msgid "request_full_card_number {currency} {user}" 452 | msgstr "Send your full {currency} card number to {user}." 453 | 454 | #: src/handlers/escrow.py 455 | msgid "asked_full_card_number {user}" 456 | msgstr "I asked {user} to send you their full card number." 457 | 458 | #: src/handlers/escrow.py 459 | msgid "ask_address {currency}" 460 | msgstr "Send your {currency} address." 461 | 462 | #: src/handlers/escrow.py 463 | msgid "bank_not_supported" 464 | msgstr "This bank is not supported." 465 | 466 | #: src/handlers/escrow.py 467 | msgid "send_first_and_last_4_digits_of_card_number {currency}" 468 | msgstr "Send first and last 4 digits of your {currency} card number." 469 | 470 | #: src/handlers/escrow.py 471 | msgid "wrong_full_card_number_receiver {user}" 472 | msgstr "You should send it to {user}, not me!" 473 | 474 | #: src/handlers/escrow.py 475 | msgid "exchange_continued {user}" 476 | msgstr "I continued the exchange with {user}." 477 | 478 | #: src/handlers/escrow.py 479 | msgid "send_name_patronymic_surname" 480 | msgstr "" 481 | "Send your name, patronymic and first letter of surname separated by " 482 | "spaces." 483 | 484 | #: src/handlers/escrow.py 485 | msgid "wrong_word_count {word_count}" 486 | msgstr "You should send {word_count} words separated by spaces." 487 | 488 | #: src/handlers/escrow.py 489 | msgid "show_order" 490 | msgstr "Show order" 491 | 492 | #: src/handlers/escrow.py 493 | msgid "accept" 494 | msgstr "Accept" 495 | 496 | #: src/handlers/escrow.py 497 | msgid "decline" 498 | msgstr "Decline" 499 | 500 | #: src/handlers/escrow.py 501 | msgid "" 502 | "escrow_offer_notification {user} {sell_amount} {sell_currency} for " 503 | "{buy_amount} {buy_currency}" 504 | msgstr "" 505 | "You got an escrow offer from {user} to sell {sell_amount} {sell_currency}" 506 | " for {buy_amount} {buy_currency}" 507 | 508 | #: src/handlers/escrow.py 509 | msgid "using {bank}" 510 | msgstr "using {bank}" 511 | 512 | #: src/handlers/escrow.py 513 | msgid "offer_sent" 514 | msgstr "Offer sent." 515 | 516 | #: src/handlers/escrow.py 517 | msgid "escrow_offer_declined" 518 | msgstr "Your escrow offer was declined." 519 | 520 | #: src/handlers/escrow.py 521 | msgid "offer_declined" 522 | msgstr "Offer was declined." 523 | 524 | #: src/handlers/escrow.py 525 | msgid "transaction_check_starting" 526 | msgstr "Starting transaction check…" 527 | 528 | #: src/handlers/escrow.py 529 | msgid "transaction_not_found" 530 | msgstr "Transaction is not found." 531 | 532 | #: src/handlers/escrow.py 533 | msgid "check" 534 | msgstr "Check transaction" 535 | 536 | #: src/handlers/escrow.py 537 | msgid "with_memo" 538 | msgstr "with memo" 539 | 540 | #: src/handlers/escrow.py 541 | msgid "transfer_information_sent" 542 | msgstr "Transfer information sent." 543 | 544 | #: src/handlers/escrow.py 545 | msgid "transaction_completion_notification_promise" 546 | msgstr "I'll notify you when transaction is complete." 547 | 548 | #: src/handlers/escrow.py 549 | msgid "cancel_after_transfer" 550 | msgstr "You can't cancel offer after transfer to escrow." 551 | 552 | #: src/handlers/escrow.py 553 | msgid "cancel_before_verification" 554 | msgstr "You can't cancel this offer until transaction will be verified." 555 | 556 | #: src/handlers/escrow.py 557 | msgid "transfer_already_confirmed" 558 | msgstr "You've already confirmed this transfer." 559 | 560 | #: src/handlers/escrow.py 561 | msgid "receiving_confirmation {currency} {user}" 562 | msgstr "Did you get {currency} from {user}?" 563 | 564 | #: src/handlers/escrow.py 565 | msgid "complete_escrow_promise" 566 | msgstr "When your transfer is confirmed, I'll complete escrow." 567 | 568 | #: src/handlers/escrow.py 569 | msgid "escrow_completing" 570 | msgstr "Escrow is being completed, just one moment." 571 | 572 | #: src/handlers/escrow.py 573 | msgid "escrow_completed" 574 | msgstr "Escrow is completed!" 575 | 576 | #: src/handlers/escrow.py 577 | msgid "escrow_sent {amount} {currency}" 578 | msgstr "I sent you {amount} {currency}." 579 | 580 | #: src/handlers/escrow.py 581 | msgid "request_validation_promise" 582 | msgstr "We'll manually validate your request and decide on the return." 583 | 584 | #: src/handlers/order.py 585 | msgid "order_not_found" 586 | msgstr "Order is not found." 587 | 588 | #: src/handlers/order.py 589 | msgid "no_more_orders" 590 | msgstr "There are no more orders." 591 | 592 | #: src/handlers/order.py 593 | msgid "no_previous_orders" 594 | msgstr "There are no previous orders." 595 | 596 | #: src/handlers/order.py 597 | msgid "escrow_unavailable" 598 | msgstr "Escrow is temporarily unavailable. Sorry for the inconvenience." 599 | 600 | #: src/handlers/order.py 601 | msgid "escrow_starting_error" 602 | msgstr "Couldn't start escrow." 603 | 604 | #: src/handlers/order.py 605 | msgid "change_to {currency}" 606 | msgstr "Change to {currency}" 607 | 608 | #: src/handlers/order.py 609 | msgid "send_exchange_sum {currency}" 610 | msgstr "Send exchange sum in {currency}." 611 | 612 | #: src/handlers/order.py 613 | msgid "edit_order_error" 614 | msgstr "Couldn't edit order." 615 | 616 | #: src/handlers/order.py 617 | msgid "send_new_buy_amount" 618 | msgstr "Send new amount of buying." 619 | 620 | #: src/handlers/order.py 621 | msgid "send_new_sell_amount" 622 | msgstr "Send new amount of selling." 623 | 624 | #: src/handlers/order.py 625 | msgid "send_new_payment_system" 626 | msgstr "Send new payment system." 627 | 628 | #: src/handlers/order.py 629 | msgid "send_new_duration {limit}" 630 | msgstr "Send new duration up to {limit} days." 631 | 632 | #: src/handlers/order.py 633 | msgid "repeat_duration_singular {days}" 634 | msgid_plural "repeat_duration_plural {days}" 635 | msgstr[0] "Repeat {days} day" 636 | msgstr[1] "Repeat {days} days" 637 | 638 | #: src/handlers/order.py 639 | msgid "send_new_comments" 640 | msgstr "Send new comments." 641 | 642 | #: src/handlers/order.py 643 | msgid "unarchive_order_error" 644 | msgstr "Couldn't unarchive order." 645 | 646 | #: src/handlers/order.py 647 | msgid "archive_order_error" 648 | msgstr "Couldn't archive order." 649 | 650 | #: src/handlers/order.py 651 | msgid "totally_sure" 652 | msgstr "Yes, I'm totally sure" 653 | 654 | #: src/handlers/order.py 655 | msgid "delete_order_confirmation" 656 | msgstr "Are you sure you want to delete the order?" 657 | 658 | #: src/handlers/order.py 659 | msgid "delete_order_error" 660 | msgstr "Couldn't delete order." 661 | 662 | #: src/handlers/order.py 663 | msgid "order_deleted" 664 | msgstr "Order is deleted." 665 | 666 | #: src/handlers/order.py 667 | msgid "hide_order_error" 668 | msgstr "Couldn't hide order." 669 | 670 | #: src/handlers/start_menu.py 671 | msgid "choose_language" 672 | msgstr "Please, choose your language." 673 | 674 | #: src/handlers/start_menu.py 675 | msgid "help_message" 676 | msgstr "" 677 | "Hello, I'm TellerBot. I can help you meet with people that you can swap " 678 | "money with.\n" 679 | "\n" 680 | "Choose one of the options on your keyboard." 681 | 682 | #: src/handlers/start_menu.py 683 | msgid "exceeded_order_creation_time_limit {orders} {hours}" 684 | msgstr "You can't create more than {orders} orders in {hours} hours." 685 | 686 | #: src/handlers/start_menu.py 687 | msgid "referral_share {link}" 688 | msgstr "Your referral link with code: {link}" 689 | 690 | #: src/handlers/start_menu.py 691 | msgid "referral_share_alias {link}" 692 | msgstr "Your referral link with username: {link}" 693 | 694 | #: src/handlers/start_menu.py 695 | msgid "choose_your_language" 696 | msgstr "Choose your language." 697 | 698 | #: src/handlers/start_menu.py 699 | msgid "request_question" 700 | msgstr "What's your question?" 701 | 702 | #: src/handlers/start_menu.py 703 | msgid "claim {amount} {currency}" 704 | msgstr "Claim {amount} {currency}" 705 | 706 | #: src/handlers/start_menu.py 707 | msgid "no_cashback" 708 | msgstr "You don't have cashback that can be claimed. Use escrow to get it." 709 | 710 | #: src/handlers/start_menu.py 711 | msgid "choose_cashback_currency" 712 | msgstr "Choose currency of cashback." 713 | 714 | #: src/handlers/start_menu.py 715 | msgid "user_not_found" 716 | msgstr "User is not found." 717 | 718 | #: src/handlers/start_menu.py 719 | msgid "no_user_argument" 720 | msgstr "Send username as an argument." 721 | 722 | #: src/handlers/start_menu.py 723 | msgid "your_subscriptions" 724 | msgstr "Your subscriptions:" 725 | 726 | #: src/handlers/start_menu.py 727 | msgid "no_subscriptions" 728 | msgstr "You don't have subscriptions." 729 | 730 | #: src/handlers/start_menu.py 731 | msgid "no_currency_argument" 732 | msgstr "Send currency or currency pair as an argument." 733 | 734 | #: src/handlers/start_menu.py 735 | msgid "subscription_added" 736 | msgstr "Subscription is added." 737 | 738 | #: src/handlers/start_menu.py 739 | msgid "subscription_exists" 740 | msgstr "Subscription already exists." 741 | 742 | #: src/handlers/start_menu.py 743 | msgid "subscription_deleted" 744 | msgstr "Subscription is deleted." 745 | 746 | #: src/handlers/start_menu.py 747 | msgid "subscription_delete_error" 748 | msgstr "Couldn't delete subscription." 749 | 750 | #: src/handlers/support.py 751 | msgid "request_cancelled" 752 | msgstr "Your request is cancelled." 753 | 754 | #: src/handlers/support.py 755 | msgid "support_response_promise" 756 | msgstr "Your message was forwarded. We'll respond to you within 24 hours." 757 | 758 | #: src/handlers/support.py 759 | msgid "reply_error_bot_blocked" 760 | msgstr "Couldn't send reply, I was blocked by the user." 761 | 762 | #: src/handlers/support.py 763 | msgid "reply_sent" 764 | msgstr "Reply is sent." 765 | 766 | #: src/handlers/support.py 767 | msgid "escrow_enabled" 768 | msgstr "Escrow was enabled." 769 | 770 | #: src/handlers/support.py 771 | msgid "escrow_disabled" 772 | msgstr "Escrow was disabled." 773 | 774 | #: src/money.py 775 | msgid "send_decimal_number" 776 | msgstr "Send decimal number." 777 | 778 | #: src/money.py 779 | msgid "send_positive_number" 780 | msgstr "Send positive number." 781 | 782 | #: src/money.py 783 | msgid "exceeded_money_limit {limit}" 784 | msgstr "Send number less than {limit}" 785 | 786 | #: src/money.py 787 | msgid "shortage_money_limit {limit}" 788 | msgstr "Send number greater than {limit}" 789 | 790 | #: src/notifications.py 791 | msgid "order_expired" 792 | msgstr "Your order has expired." 793 | 794 | #: src/whitelist.py 795 | msgid "without_gateway" 796 | msgstr "Without gateway" 797 | --------------------------------------------------------------------------------