diff --git a/.gitignore b/.gitignore index 88aec55..5116c7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,3 @@ -*.pyc -pycache -myconfig.py -virtualenv -.idea -.vscode -.DS_Store +.atico/ .env -.pytest_cache -.python-version bot.log diff --git a/.travis.yml b/.travis.yml index 964d029..33cb284 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,10 @@ dist: bionic language: python python: - - 3.6 - - 3.7 + - "3.9" + - "3.10" + - "3.11" + - "3.12" install: - pip install pipenv - pipenv install -d diff --git a/README.md b/README.md index b5dc7da..466cf50 100644 --- a/README.md +++ b/README.md @@ -9,37 +9,47 @@ Telegram Bot made in Python to automate different tasks of [Python Canarias](htt ## Installation -Create a virtualenv for Python3 and install dependencies. In this -example we are using pyenv: +Create a virtualenv for Python3 and install bot. ~~~console -$ pyenv virtualenv 3.12.4 pydeckard -$ pyenv activate pydeckard -$ pip install -r requirements.txt +$ python3 -m venv /path/to/new/virtual/environment +$ cd /path/to/new/virtual/environment +$ source ./bin/activate +$ ./bin/pip3 install git+https://github.com/pythoncanarias/pydeckard.git ~~~ -A developer needs to install a few more packages: +As a developer, you must install it in this other way: ~~~console -$ pip install -r dev-requirements.txt +$ git clone https://github.com/pythoncanarias/pydeckard.git +$ cd pydeckard +$ python3 -m venv venv +$ source ./venv/bin/activate +$ pip3 install -e .[dev] ~~~ -Next step is to set your bot token for development: +After installation, the next step is to create the .env configuration file and the file for automatic program +startup. +During the process, you will be asked to enter your Telegram token and will be prompted with other +configuration-related questions. The only required item is the Telegram token. +To do this, activate the virtual environment and run: ~~~console -$ echo 'TELEGRAM_BOT_TOKEN = ""' > .env +$ pydeckard --setup ~~~ -Now you can launch the bot with: +You can now launch the bot, activating the virtual environment and running:: ~~~console -$ python bot.py +$ pydeckard ~~~ -You can use the flag `--verbose` (or `-v') to get more information in rhe console: +...or delegate the startup of the application to your operating system using the instructions that setup has provided. + +You can view the bot log using: ~~~console -$ python bot.py --verbose +$ journalctl -u pydeckard.service -f ~~~ @@ -48,5 +58,5 @@ $ python bot.py --verbose Use pytest: ~~~console -$ python -m pytest +$ python3 -m pytest ~~~ diff --git a/fabfile.py b/fabfile.py deleted file mode 100644 index 92c8966..0000000 --- a/fabfile.py +++ /dev/null @@ -1,11 +0,0 @@ -from fabric.api import env, local, cd, run - -env.hosts = ["pythoncanarias.es"] - - -def deploy(): - local("git push") - with cd("~/pydeckard"): - run("git pull") - run("pipenv install") - run("supervisorctl restart pydeckard") diff --git a/pydeckard.service b/pydeckard.service deleted file mode 100644 index 50a6d5c..0000000 --- a/pydeckard.service +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=PyDeckard - -[Service] -Restart=always -WorkingDirectory=/home/jileon/pydeckard/ -ExecStart=/home/jileon/.pyenv/versions/3.12.4/envs/pydeckard/bin/python bot.py - -[Install] -WantedBy=multi-user.target -Alias=PyDeckard.service diff --git a/pydeckard/__init__.py b/pydeckard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot.py b/pydeckard/bot.py similarity index 92% rename from bot.py rename to pydeckard/bot.py index 4f6a433..b4928e9 100644 --- a/bot.py +++ b/pydeckard/bot.py @@ -13,8 +13,8 @@ from telegram.ext import ApplicationBuilder, filters, MessageHandler, CommandHandler, ContextTypes from telegram.constants import ParseMode -import config -import utils +from pydeckard import config +from pydeckard import utils class DeckardBot(): @@ -22,31 +22,27 @@ class DeckardBot(): def __init__(self): self.get_options() self.set_logger() - self.verbose = False self.started_at = DateTime.now() def get_options(self): parser = argparse.ArgumentParser( prog='bot', description='PyDeckard Bot', - epilog='Text at the bottom of help', + epilog='', ) - parser.add_argument('-v', '--verbose', action='store_true') + parser.add_argument('--setup', action='store_true', help='Start the setup wizard') args = parser.parse_args() - self.verbose = args.verbose + if args.setup: + utils.setup_bot() def set_logger(self): - self.logger = logging.getLogger('bot') - - file_handler = RotatingFileHandler('bot.log', maxBytes=1_000_000, backupCount=5) - console_handler = logging.NullHandler() - if self.verbose: - console_handler = logging.StreamHandler() + self.logger = logging.getLogger('pydeckard') + console_handler = logging.StreamHandler() logging.basicConfig( level=logging.WARNING, # Pone el nivel de todos los logger a WARNING format='%(asctime)s [%(name)s] %(levelname)s: %(message)s', - handlers=[file_handler,console_handler], + handlers=[console_handler], force=True ) @@ -188,6 +184,14 @@ def run(self): application.run_polling(poll_interval=config.POLL_INTERVAL) -if __name__ == "__main__": +def main(): + """ + Arranca el bot + """ + bot = DeckardBot() bot.run() + + +if __name__ == "__main__": + main() diff --git a/config.py b/pydeckard/config.py similarity index 99% rename from config.py rename to pydeckard/config.py index e8f3ced..e59ab2e 100644 --- a/config.py +++ b/pydeckard/config.py @@ -103,7 +103,7 @@ def bot_replies_enabled() -> bool: ("elixir",): "BIBA ELICSÍR!! ¥", ("cobol",): "BIBA KOBOL!! 💾", ("fortran",): "BIBA FORRRTRÁN!! √", - ("c\+\+",): "BIBA CEMASMÁS!! ⊕", + ("c++",): "BIBA CEMASMÁS!! ⊕", ("javascript",): "BIBA JABAESCRIP!! 🔮", ("php",): "BIBA PEACHEPÉ!.! ⛱", ("perl",): "BIBA PERRRRRL! 🐫", diff --git a/pydeckard/utils.py b/pydeckard/utils.py new file mode 100644 index 0000000..ce97488 --- /dev/null +++ b/pydeckard/utils.py @@ -0,0 +1,291 @@ +import functools +import datetime +import grp +import platform +import pwd +import random +import re +import sys +from pathlib import Path +from typing import Tuple, Optional, NamedTuple + +from telegram import User +from pydeckard import config + + +def is_chinese(c): + """ + Returns True if the character passed as parameter is a Chinese one + """ + num = ord(c) + return any( + ( + 0x2E80 <= num <= 0x2FD5, + 0x3190 <= num <= 0x319F, + 0x3400 <= num <= 0x4DBF, + 0x4E00 <= num <= 0x9FCC, + 0x6300 <= num <= 0x77FF, + ) + ) + + +def too_much_chinese_chars(s): + letters = list(s) + num_chinese_chars = sum([is_chinese(c) for c in letters]) + percent = num_chinese_chars / len(letters) + # More than allowed chars are Chinese + return percent > config.MAX_CHINESE_CHARS_PERCENT + + +def is_valid_name(user: User): + return len(user.first_name) <= config.MAX_HUMAN_USERNAME_LENGTH + + +def is_tgmember_sect(first_name: str): + return "tgmember.com" in first_name.lower() + + +def is_bot(user: User): + """ + Returns True if a new user is a bot. So far only the length of the + username is checked. In the future, we can add more conditions and use a + score/weight of the probability of being a bot. + + :param user: The new User + :type user: User + :return: True if the new user is considered a bot (according to our rules) + :rtype: bool + """ + # Add all the checks that you consider necessary + return any( + ( + not is_valid_name(user), + too_much_chinese_chars(user.first_name), + is_tgmember_sect(user.first_name), + ) + ) + + +@functools.lru_cache() +def get_reply_regex(trigger_words: Tuple[str]): + """ + Build a regex to match on the trigger words + """ + pattern = "|".join([fr"\b{word}\b" for word in trigger_words]) + return re.compile(pattern, re.IGNORECASE) + + +def bot_wants_to_reply() -> bool: + return random.random() < config.VERBOSITY + + +class BotReplySpec(NamedTuple): + message: str + trigger: str + reply: str + + +def triggers_reply(message: str) -> Optional[BotReplySpec]: + for trigger_words, bot_reply in config.REPLIES.items(): + regex = get_reply_regex(trigger_words) + match = regex.search(message) + if match is not None and bot_wants_to_reply(): + # When a match is found, check if the bot will reply based on its + # reply likelihood + if not isinstance(bot_reply, str): + # If value is a list then pick random string from + # multiple values: + bot_reply = random.choice(bot_reply) + return BotReplySpec(message, match.group(0), bot_reply) + return None + + +def pluralise(number: int, singular: str, plural: Optional[str] = None) -> str: + if plural is None: + plural = f"{singular}s" + return singular if number == 1 else plural + + +def since(reference) -> str: + """Returns a textual description of time passed. + + Parameter: + - reference: datetime is the datetime used to get the difference + ir delta. + """ + + dt = datetime.datetime.now() + delta = dt - reference + buff = [] + days = delta.days + if days: + buff.append(f"{days} {pluralise(days, 'day')}") + seconds = delta.seconds + if seconds > 3600: + hours = seconds // 3600 + buff.append(f"{hours} {pluralise(hours, 'hour')}") + seconds %= 3600 + minutes = seconds // 60 + if minutes > 0: + buff.append(f"{minutes} {pluralise(minutes, 'minute')}") + seconds %= 60 + buff.append(f"{seconds} {pluralise(seconds, 'second')}") + return " ".join(buff) + + +def validate_input(prompt_head, acceptable=None, typus=None): + """ + This function is designed to capture the parameters that will be used to configure the bot. + It captures input and validates the data obtained. + Steps: + Create a text string to use as a prompt. + Request the input. + Validate the received data. + Return the validated data or None. + Parameter capture can be interrupted with Ctrl+C + + Arguments + prompt_head (str): Start of the message to be displayed to the user. + acceptable (list/tuple, optional): Whitelist of values or range (min, max). + typus (callable): Data type to convert the input to. + + Return + The data validated and converted to type 'typus' or None + """ + + prompt_tail = '' + + if isinstance(acceptable, list): + prompt_tail = f" ({'/'.join(map(str, acceptable))})" + elif isinstance(acceptable, tuple): + prompt_tail = f' ({acceptable[0]}-{acceptable[1]})' + + prompt = f'{prompt_head}{prompt_tail}: ' + + while True: + try: + data = input(prompt).strip() + except KeyboardInterrupt: + raise + + if not (data and callable(typus)): + return None + + try: + if typus is int: + data = int(data, 0) + elif typus is str and acceptable and all(x.isupper() for x in acceptable): + data = data.upper() + elif typus is str or typus is float: + data = typus(data) + else: + return None + except ValueError: + print(f'El valor debe ser de tipo {typus.__name__}') + continue + + if isinstance(acceptable, list) and data not in acceptable: + print(f'El valor debe ser una de estas opciones: {"/".join(map(str, acceptable))}') + continue + + if isinstance(acceptable, tuple): + if not (acceptable[0] <= data <= acceptable[1]): + print(f'El valor debe estar entre {acceptable[0]} y {acceptable[1]}.') + continue + + return data + + +def setup_bot(): + """ + A wizard starts to configure the bot and create an automatic startup system based on the operating system. + It performs an input for each required configuration parameter. + The "parameters" list contains all the defined parameters, each as a tuple of four elements: + parameter name, + prompt for input, + a tuple with two values to indicate a range of allowed values OR a list with values to indicate the + different allowed options OR None, + a class to cast the value to the allowed type + """ + + root_path = Path(sys.prefix) + bin_path = Path(sys.executable).parent + bot_executable = bin_path / 'pydeckard' + env_path = root_path / '.env' + system_name = platform.system() + + print(f'\n--- Asistente de configuración para PyDeckard (SO: {system_name}) ---\n\n') + + parameters = [('TELEGRAM_BOT_TOKEN', 'Introduzca el Token del Bot', None, str), + ('BOT_VERBOSITY', 'Nivel de verbosidad', (0.0, 1.0), float), + ('LOG_LEVEL', 'Nivel de registro de logs', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str), + ('POLL_INTERVAL', 'Intervalo de polling para la API de Telegram', (1, 10), int), + ('BOT_GREETING', 'Saludo del bot', None, str), + ('MAX_HUMAN_USERNAME_LENGTH', 'Longitud máxima del username', None, int), + ('MAX_CHINESE_CHARS_PERCENT', 'Máximo porcentaje de caracteres chinos en username', + (0.0, 1.0), float), + ('WELCOME_DELAY', 'Tiempo de retardo para la bienvenida (seg)', None, int), ] + + try: + items_env = {key: validate_input(*args) for key, *args in parameters} + except KeyboardInterrupt: + print('Asistente cancelado por el usuario.') + sys.exit(1) + + with open(env_path, 'w') as fout: + lines = [f'{key}={value}\n' for key, value in items_env.items() if value] + fout.writelines(lines) + + print(f'\n\nArchivo .env creado en {root_path}') + + if system_name == 'Linux': + stat_info = root_path.stat() + + user_name = pwd.getpwuid(stat_info.st_uid).pw_name + group_name = grp.getgrgid(stat_info.st_gid).gr_name + + service_path = root_path / 'pydeckard.service' + + service_content = f"""[Unit] +Description=PyDeckard +After=network.target + +[Service] +Type=simple +User={user_name} +Group={group_name} +WorkingDirectory={root_path} +ExecStart={bot_executable} +Environment=PYTHONUNBUFFERED=1 +Restart=always + +[Install] +WantedBy=multi-user.target +Alias=PyDeckard.service +""" + + with open(service_path, 'w') as f: + f.write(service_content) + + print(f'\nArchivo pydeckard.service creado en {root_path}') + + print(f'\nPara configurar, activar e iniciar el service en systemd ejecute los siguientes comandos:') + + print(f'\nsudo cp {service_path} /etc/systemd/system/') + print('sudo systemctl daemon-reload') + print('sudo systemctl enable --now pydeckard') + + sys.exit(0) + + elif system_name == 'Darwin': + print('Entorno macOS detectado, configuración realizada, pregúntele a Apple® como arrancarlo.') + sys.exit(1) + + elif system_name == 'Windows': + print('Entorno Windows detectado, configuración realizada, pregúntele a Microsoft® como arrancarlo.') + sys.exit(1) + + elif system_name == 'Java': + print('Entorno Jython detectado. Usted mismo.') + sys.exit(1) + diff --git a/pyproject.toml b/pyproject.toml index ecba4b1..077a01e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,43 @@ +[build-system] +requires = ['setuptools>=61.0'] +build-backend = 'setuptools.build_meta' + +[project] +name = 'pydeckard' +description = 'Un bot de Telegram para el canal de Python Canarias' +requires-python = '>=3.10' +authors = [ + { name = 'Python Canarias', email = 'info@pythoncanarias.es' }, +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Operating System :: OS Independent", + "Intended Audience :: Developers", + "Topic :: Communications :: Chat", +] +license = "GPL-3.0-or-later" +license-files = ["LICENSE"] +dynamic = ['dependencies', 'readme', 'optional-dependencies'] +version="0.1.0" + +[project.urls] +Homepage = "https://github.com/pythoncanarias/pydeckard.git" + +[tool.setuptools.dynamic] +readme = { file = ['README.md', ] } +dependencies = { file = 'requirements.txt' } + +[tool.setuptools.dynamic.optional-dependencies] +dev = { file = ["dev-requirements.txt"] } + +[tool.setuptools.packages.find] +include = ["pydeckard*"] +where = ["."] + +[project.scripts] +pydeckard = "pydeckard.bot:main" + [tool.ruff] line-length = 120 target-version = "py312" - diff --git a/run.sh b/run.sh deleted file mode 100755 index 144be68..0000000 --- a/run.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/bash -# Master script. - -cd "$(dirname "$0")" -source ~/.pyenv/versions/3.12.4/envs/pydeckard/bin/activate -exec python bot.py diff --git a/test/test_bot_detection.py b/test/test_bot_detection.py index b74cd12..51f35c0 100644 --- a/test/test_bot_detection.py +++ b/test/test_bot_detection.py @@ -1,6 +1,6 @@ from unittest.mock import Mock import pytest -import utils +from pydeckard import utils # testing is_chinese diff --git a/test/test_replies.py b/test/test_replies.py index e893a7a..2868b3d 100644 --- a/test/test_replies.py +++ b/test/test_replies.py @@ -1,7 +1,6 @@ import pytest -import config -import utils +from pydeckard import config, utils @pytest.fixture() diff --git a/test/test_utils.py b/test/test_utils.py index 54d7450..9b656ce 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -6,55 +6,490 @@ import pytest from freezegun import freeze_time -import utils +from pydeckard.utils import validate_input, since @freeze_time("2019-05-16 13:35:16") def test_since_second(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "1 second" + assert since(ref) == "1 second" @freeze_time("2019-05-16 13:35:21") def test_since_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "6 seconds" + assert since(ref) == "6 seconds" @freeze_time("2019-05-16 13:36:21") def test_since_minute_and_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "1 minute 6 seconds" + assert since(ref) == "1 minute 6 seconds" @freeze_time("2019-05-16 13:37:21") def test_since_minutes_and_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "2 minutes 6 seconds" + assert since(ref) == "2 minutes 6 seconds" @freeze_time("2019-05-16 14:37:21") def test_since_hour_and_minutes_and_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "1 hour 2 minutes 6 seconds" + assert since(ref) == "1 hour 2 minutes 6 seconds" @freeze_time("2019-05-16 15:37:21") def test_since_hours_and_minutes_and_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "2 hours 2 minutes 6 seconds" + assert since(ref) == "2 hours 2 minutes 6 seconds" @freeze_time("2019-05-17 15:37:21") def test_since_day_hours_and_minutes_and_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "1 day 2 hours 2 minutes 6 seconds" + assert since(ref) == "1 day 2 hours 2 minutes 6 seconds" @freeze_time("2019-05-19 15:37:21") def test_since_days_hours_and_minutes_and_seconds(): ref = datetime.datetime(2019, 5, 16, 13, 35, 15) - assert utils.since(ref) == "3 days 2 hours 2 minutes 6 seconds" + assert since(ref) == "3 days 2 hours 2 minutes 6 seconds" + + +def test_tipo_none0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', None, None) is None + + +def test_tipo_none1(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', None, None) is None + + +def test_tipo_none2(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '1.0') + + assert validate_input('Dato', None, None) is None + + +def test_tipo_none3(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: 'Texto') + + assert validate_input('Dato', None, None) is None + + +def test_tipo_texto0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', None, str) is None + + +def test_tipo_texto1(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', None, str) == '1' + + +def test_tipo_texto2(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '1.0') + + assert validate_input('Dato', None, str) == '1.0' + + +def test_tipo_texto3(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: 'Texto') + + assert validate_input('Dato', None, str) == 'Texto' + + +def test_tipo_int0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', None, int) is None + + +def test_tipo_int1(monkeypatch): + # Decimal + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', None, int) == 1 + + +def test_tipo_int2(monkeypatch): + # Octal + monkeypatch.setattr('builtins.input', lambda _: '0o10') + + assert validate_input('Dato', None, int) == 8 + + +def test_tipo_int3(monkeypatch): + # Hexadecimal + monkeypatch.setattr('builtins.input', lambda _: '0xff') + + assert validate_input('Dato', None, int) == 255 + + +def test_tipo_int4(monkeypatch): + # Binario + monkeypatch.setattr('builtins.input', lambda _: '0b11') + + assert validate_input('Dato', None, int) == 3 + + +def test_tipo_int5(monkeypatch): + # Separador _ + monkeypatch.setattr('builtins.input', lambda _: '1_000_000') + + assert validate_input('Dato', None, int) == 1000000 + + +def test_tipo_int6(monkeypatch): + # Cero negativo + monkeypatch.setattr('builtins.input', lambda _: '-0') + + assert validate_input('Dato', None, int) == 0 + + +def test_tipo_int7(monkeypatch): + # Decimal con espacios + monkeypatch.setattr('builtins.input', lambda _: ' 10 ') + + assert validate_input('Dato', None, int) == 10 + + +def test_tipo_int8(monkeypatch): + # Hexadecimal con espacios + monkeypatch.setattr('builtins.input', lambda _: ' 0xff ') + + assert validate_input('Dato', None, int) == 255 + + +def test_tipo_int15(monkeypatch, capsys): + # Float + entradas = iter(['1.0', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', None, int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_tipo_int16(monkeypatch, capsys): + # Texto + entradas = iter(['Texto', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', None, int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_tipo_float0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', None, float) is None + + +def test_tipo_float1(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', None, float) == 1.0 + + +def test_tipo_float2(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '1.0') + + assert validate_input('Dato', None, float) == 1.0 + + +def test_tipo_float3(monkeypatch, capsys): + entradas = iter(['Texto', '1.0']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', None, float) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo float' in imprime + assert resultado == 1.0 + + +def test_tipo_float4(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '-0.0') + + assert validate_input('Dato', None, float) == -0.0 + + +def test_rango_tipo_int0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', (0, 1), int) is None + + +def test_rango_tipo_int1(monkeypatch): + # En rango entero + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', (0, 10), int) == 1 + + +def test_rango_tipo_int2(monkeypatch, capsys): + # Fuera de rango entero + entradas = iter(['20', '-2', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0, 10), int) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe estar entre 0 y 10' in imprime + assert resultado == 1 + + +def test_rango_tipo_int3(monkeypatch, capsys): + # En rango float + entradas = iter(['5.0', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0, 10), int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_rango_tipo_int4(monkeypatch, capsys): + # Fuera de rango float + entradas = iter(['20.0', '-1.0', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0, 10), int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_rango_tipo_int5(monkeypatch, capsys): + # Texto + entradas = iter(['texto', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0, 10), int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_rango_tipo_float0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', (0.0, 1.0), float) is None + + +def test_rango_tipo_float1(monkeypatch): + # En rango entero + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', (0.0, 1.0), float) == 1.0 + + +def test_rango_tipo_float2(monkeypatch, capsys): + # Fuera de rango entero + entradas = iter(['2', '-2','1.0']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0.0, 1.0), float) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe estar entre 0.0 y 1.0' in imprime + assert resultado == 1.0 + + +def test_rango_tipo_float3(monkeypatch): + # En rango float + monkeypatch.setattr('builtins.input', lambda _: '1.0') + + assert validate_input('Dato', (0.0, 1.0), float) == 1.0 + + +def test_rango_tipo_float4(monkeypatch, capsys): + # Fuera de rango float + entradas = iter(['2.0', '-1.0', '1.0']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0.0, 1.0), float) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe estar entre 0.0 y 1.0' in imprime + assert resultado == 1.0 + + +def test_rango_tipo_float5(monkeypatch, capsys): + # Texto + entradas = iter(['texto', '1.0']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', (0.0, 1.0), float) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo float' in imprime + assert resultado == 1.0 + + +def test_option_tipo_int0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', [-2, -1, 0, 1, 2, 3], int) is None + + +def test_option_tipo_int1(monkeypatch): + # En lista entero + monkeypatch.setattr('builtins.input', lambda _: '1') + + assert validate_input('Dato', [-2, -1, 0, 1, 2, 3], int) == 1 + + +def test_option_tipo_int2(monkeypatch, capsys): + # Fuera de lista entero + entradas = iter(['20', '-23', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', [-2, -1, 0, 1, 2, 3], int) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe ser una de estas opciones: -2/-1/0/1/2/3' in imprime + assert resultado == 1 + + +def test_option_tipo_int4(monkeypatch, capsys): + # Fuera de lista float + entradas = iter(['20.0', '-10.0', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', [-2, -1, 0, 1, 2, 3], int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_option_tipo_int5(monkeypatch, capsys): + # Texto + entradas = iter(['texto', '1']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', [-2, -1, 0, 1, 2, 3], int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado == 1 + + +def test_option_tipo_int6(monkeypatch, capsys): + # En lista vacia entero + entradas = iter(['texto', '']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', [], int) + imprime = capsys.readouterr().out + + assert 'El valor debe ser de tipo int' in imprime + assert resultado is None + + +def test_option_tipo_texto0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: '') + + assert validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) is None + + +def test_option_tipo_texto1(monkeypatch, capsys): + # Entero + entradas = iter(['20', 'DEBUG']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe ser una de estas opciones: DEBUG/INFO/WARNING/ERROR' in imprime + assert resultado == 'DEBUG' + + +def test_option_tipo_texto2(monkeypatch, capsys): + # float + entradas = iter(['20.0', 'DEBUG']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe ser una de estas opciones: DEBUG/INFO/WARNING/ERROR' in imprime + assert resultado == 'DEBUG' + + +def test_option_tipo_texto3(monkeypatch, capsys): + # Fuera de lista texto + entradas = iter(['HOLA', 'DEBUG']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe ser una de estas opciones: DEBUG/INFO/WARNING/ERROR' in imprime + assert resultado == 'DEBUG' + + +def test_option_tipo_texto4(monkeypatch): + # Texto + monkeypatch.setattr('builtins.input', lambda _: 'DEBUG') + + assert validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) == 'DEBUG' + + +def test_option_tipo_texto5(monkeypatch): + # Texto minúscula + monkeypatch.setattr('builtins.input', lambda _: 'debug') + + assert validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) == 'DEBUG' + + +def test_option_tipo_texto6(monkeypatch): + # Texto mezcla minúsculas + monkeypatch.setattr('builtins.input', lambda _: 'DebuG') + + assert validate_input('Dato', ['DEBUG', 'INFO', 'WARNING', 'ERROR'], str) == 'DEBUG' + + +def test_option_tipo_texto7(monkeypatch, capsys): + # En lista vacia texto + entradas = iter(['texto', '']) + monkeypatch.setattr("builtins.input", lambda _: next(entradas)) + + resultado = validate_input('Dato', [], str) + imprime = capsys.readouterr().out + + assert 'Error: El valor debe ser una de estas opciones: ' in imprime + assert resultado is None + + +def test_tipo_raro0(monkeypatch): + monkeypatch.setattr('builtins.input', lambda _: 'texto') + + assert validate_input('Dato', None, bool) is None + + +def test_tipo_raro1(monkeypatch): + # Tipo no callable + monkeypatch.setattr('builtins.input', lambda _: 'texto') + + assert validate_input('Dato', None, object()) is None if __name__ == "__main__": diff --git a/utils.py b/utils.py deleted file mode 100644 index d0c8e4f..0000000 --- a/utils.py +++ /dev/null @@ -1,128 +0,0 @@ -import functools -import datetime -import random -import re -from typing import Tuple, Optional, NamedTuple - -from telegram import User -import config - - -def is_chinese(c): - """ - Returns True if the character passed as parameter is a Chinese one - """ - num = ord(c) - return any( - ( - 0x2E80 <= num <= 0x2FD5, - 0x3190 <= num <= 0x319F, - 0x3400 <= num <= 0x4DBF, - 0x4E00 <= num <= 0x9FCC, - 0x6300 <= num <= 0x77FF, - ) - ) - - -def too_much_chinese_chars(s): - letters = list(s) - num_chinese_chars = sum([is_chinese(c) for c in letters]) - percent = num_chinese_chars / len(letters) - # More than allowed chars are Chinese - return percent > config.MAX_CHINESE_CHARS_PERCENT - - -def is_valid_name(user: User): - return len(user.first_name) <= config.MAX_HUMAN_USERNAME_LENGTH - - -def is_tgmember_sect(first_name: str): - return "tgmember.com" in first_name.lower() - - -def is_bot(user: User): - """ - Returns True if a new user is a bot. So far only the length of the - username is checked. In the future, we can add more conditions and use a - score/weight of the probability of being a bot. - - :param user: The new User - :type user: User - :return: True if the new user is considered a bot (according to our rules) - :rtype: bool - """ - # Add all the checks that you consider necessary - return any( - ( - not is_valid_name(user), - too_much_chinese_chars(user.first_name), - is_tgmember_sect(user.first_name), - ) - ) - - -@functools.lru_cache() -def get_reply_regex(trigger_words: Tuple[str]): - """ - Build a regex to match on the trigger words - """ - pattern = "|".join([fr"\b{word}\b" for word in trigger_words]) - return re.compile(pattern, re.IGNORECASE) - - -def bot_wants_to_reply() -> bool: - return random.random() < config.VERBOSITY - - -class BotReplySpec(NamedTuple): - message: str - trigger: str - reply: str - - -def triggers_reply(message: str) -> Optional[BotReplySpec]: - for trigger_words, bot_reply in config.REPLIES.items(): - regex = get_reply_regex(trigger_words) - match = regex.search(message) - if match is not None and bot_wants_to_reply(): - # When a match is found, check if the bot will reply based on its - # reply likelihood - if not isinstance(bot_reply, str): - # If value is a list then pick random string from - # multiple values: - bot_reply = random.choice(bot_reply) - return BotReplySpec(message, match.group(0), bot_reply) - return None - - -def pluralise(number: int, singular: str, plural: Optional[str] = None) -> str: - if plural is None: - plural = f"{singular}s" - return singular if number == 1 else plural - - -def since(reference) -> str: - """Returns a textual description of time passed. - - Parameter: - - reference: datetime is the datetime used to get the difference - ir delta. - """ - - dt = datetime.datetime.now() - delta = dt - reference - buff = [] - days = delta.days - if days: - buff.append(f"{days} {pluralise(days, 'day')}") - seconds = delta.seconds - if seconds > 3600: - hours = seconds // 3600 - buff.append(f"{hours} {pluralise(hours, 'hour')}") - seconds %= 3600 - minutes = seconds // 60 - if minutes > 0: - buff.append(f"{minutes} {pluralise(minutes, 'minute')}") - seconds %= 60 - buff.append(f"{seconds} {pluralise(seconds, 'second')}") - return " ".join(buff)