diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..59c8e7a --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +TELEGRAM_BOT_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +VERBOSITY=0.33 +LOG_LEVEL=WARNING +POLL_INTERVAL=3 +BOT_GREETING="Hi! I'm a friendly, crazy slightly psychopath robot" +MAX_HUMAN_USERNAME_LENGTH=100 +MAX_CHINESE_CHARS_PERCENT=0.15 +WELCOME_DELAY=330 diff --git a/.gitignore b/.gitignore index 88aec55..e94c2fe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,3 @@ -*.pyc -pycache -myconfig.py -virtualenv -.idea -.vscode -.DS_Store -.env -.pytest_cache -.python-version bot.log +.atico/ +.env diff --git a/.travis.yml b/.travis.yml index 964d029..8f1b069 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,11 @@ dist: bionic language: python python: - - 3.6 - - 3.7 +python: + - "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..532fecf 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,19 @@ 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: +example we are using python -m venv: ~~~console -$ pyenv virtualenv 3.12.4 pydeckard -$ pyenv activate pydeckard -$ pip install -r requirements.txt +$ python -m venv pydeckard +$ cd pydeckard +$ source ./bin/activate +$ ./bin/pip install git+https://github.com/misanram/pydeckard.git@Instalar-desde-GitHub ~~~ A developer needs to install a few more packages: ~~~console -$ pip install -r dev-requirements.txt +$ ./bin/pip install git+https://github.com/misanram/pydeckard.git@Instalar-desde-GitHub[dev] ~~~ Next step is to set your bot token for development: @@ -33,13 +34,19 @@ $ echo 'TELEGRAM_BOT_TOKEN = ""' > .env Now you can launch the bot with: ~~~console -$ python bot.py +$ python3 bot.py ~~~ -You can use the flag `--verbose` (or `-v') to get more information in rhe console: +~~~systemd + + + + + +You can use the flag `--verbose` (or `-v') to get more information in the console: ~~~console -$ python bot.py --verbose +$ python3 bot.py --verbose ~~~ @@ -48,5 +55,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.service.example b/pydeckard.service.example new file mode 100644 index 0000000..c64515a --- /dev/null +++ b/pydeckard.service.example @@ -0,0 +1,18 @@ +[Unit] +Description=PyDeckard +After=network.target + +[Service] +User=el_que_ejecute_el_bot +Group=el_que_ejecute_el_bot +Restart=always +WorkingDirectory=/ruta_al_entorno_virtual +ExecStart=/ruta_al_entorno_virtual/bin/bot +# La onfiguración del bot puede hacerse: + A) Colocando el archivo .env en el WorkingDirectory + B) Colocando el archivo .env en cualquier directorio que User pueda leer y declarandolo en + EnvironmentFile=/cualquier_directorio/.env + +[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..6607694 100644 --- a/bot.py +++ b/pydeckard/bot.py @@ -1,20 +1,19 @@ #!/usr/bin/enb python3 - from datetime import datetime as DateTime import itertools import argparse import logging import sys import time -from logging.handlers import RotatingFileHandler import telegram from telegram import Update from telegram.ext import ApplicationBuilder, filters, MessageHandler, CommandHandler, ContextTypes from telegram.constants import ParseMode -import config -import utils + +from pydeckard import utils +from pydeckard import config class DeckardBot(): @@ -22,7 +21,6 @@ class DeckardBot(): def __init__(self): self.get_options() self.set_logger() - self.verbose = False self.started_at = DateTime.now() def get_options(self): @@ -31,22 +29,20 @@ def get_options(self): description='PyDeckard Bot', epilog='Text at the bottom of help', ) - 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() - + console_handler = logging.StreamHandler() logging.basicConfig( - level=logging.WARNING, # Pone el nivel de todos los logger a WARNING + level=logging.DEBUG, # 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 ) @@ -57,6 +53,7 @@ def set_logger(self): def trace(self, msg): self.logger.info(msg) + async def command_status(self, update: Update, context: ContextTypes.DEFAULT_TYPE): self.trace('Received command: /status') python_version = sys.version.split(maxsplit=1)[0] @@ -188,6 +185,9 @@ def run(self): application.run_polling(poll_interval=config.POLL_INTERVAL) -if __name__ == "__main__": +def main(): bot = DeckardBot() bot.run() + +if __name__ == "__main__": + main() diff --git a/config.py b/pydeckard/config.py similarity index 97% rename from config.py rename to pydeckard/config.py index e8f3ced..b15cd1c 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!! ⊕", + (r"c\+\+",): "BIBA CEMASMÁS!! ⊕", ("javascript",): "BIBA JABAESCRIP!! 🔮", ("php",): "BIBA PEACHEPÉ!.! ⛱", ("perl",): "BIBA PERRRRRL! 🐫", @@ -121,7 +121,3 @@ def bot_replies_enabled() -> bool: "tiempo... como lágrimas en la lluvia. Es hora de morir. 🔫", ("python", "pitón", "piton"): THE_ZEN_OF_PYTHON, } - -MAXLEN_FOR_USERNAME_TO_TREAT_AS_HUMAN = 100 - -CHINESE_CHARS_MAX_PERCENT = 0.15 diff --git a/pydeckard/utils.py b/pydeckard/utils.py new file mode 100644 index 0000000..45a0b60 --- /dev/null +++ b/pydeckard/utils.py @@ -0,0 +1,288 @@ +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 + :typus 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 / 'bot' + 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), + ('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'\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..f058c9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,44 @@ +[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.12", + "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.7" + +[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] +bot = "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..86687b8 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,60 +1,492 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- import datetime 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)