├── callbacks ├── __init__.py └── handler.py ├── keyboards ├── __init__.py └── keyboards.py ├── .python-version ├── commands ├── tagger │ ├── __init__.py │ └── all_tagger.py ├── pelicula │ ├── subs │ │ └── __init__.py │ ├── constants.py │ ├── keyboard.py │ ├── command.py │ ├── callback.py │ └── utils.py ├── subte │ ├── utils.py │ ├── constants.py │ ├── suscribers │ │ ├── constants.py │ │ ├── utils.py │ │ ├── models.py │ │ ├── db.py │ │ └── command.py │ ├── command.py │ └── updates │ │ ├── utils.py │ │ └── alerts.py ├── github │ └── command.py ├── hoypido │ ├── callback.py │ ├── keyboard.py │ ├── command.py │ └── utils.py ├── retro │ ├── handler.py │ ├── models.py │ └── commands.py ├── yts │ ├── constants.py │ ├── command.py │ ├── utils.py │ └── callback_handler.py ├── meeting │ ├── constants.py │ ├── db_operations.py │ ├── models.py │ ├── keyboard.py │ ├── command.py │ └── conversation_handler.py ├── aproximacion │ ├── constants.py │ ├── conversation_handler.py │ ├── keyboard.py │ ├── jacobi.py │ ├── utils.py │ ├── gauss_seidel.py │ └── state_handlers.py ├── dolar │ ├── callback.py │ ├── keyboards.py │ ├── command.py │ └── utils.py ├── dolar_futuro │ ├── constants.py │ └── command.py ├── snippets │ ├── constants.py │ ├── utils.py │ └── command.py ├── feriados │ ├── constants.py │ ├── command.py │ └── utils.py ├── posiciones │ ├── command.py │ └── utils.py ├── cartelera │ └── command.py ├── start │ └── command.py ├── serie │ ├── constants.py │ ├── keyboard.py │ ├── command.py │ ├── callbacks.py │ └── utils.py ├── feedback │ └── command.py ├── misc │ └── commands.py ├── register │ ├── db.py │ └── command.py ├── partido │ └── command.py ├── hastebin │ └── command.py └── youtube │ └── command.py ├── requirements_dev.txt ├── utils ├── exceptions.py ├── constants.py ├── utils.py └── decorators.py ├── .env.sample ├── requirements.in ├── Dockerfile ├── Justfile ├── tests ├── commands │ ├── snippets │ │ └── test_regexps.py │ ├── feriados │ │ └── test_utils.py │ ├── aproximacion │ │ └── test_methods.py │ ├── yts │ │ └── test_handle_callback.py │ └── subte │ │ └── updates │ │ └── test_alerts.py └── utils │ └── test_decorators.py ├── inlinequeries └── snippets.py ├── requirements.txt ├── .gitignore ├── README.md └── main.py /callbacks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | cuervot 2 | -------------------------------------------------------------------------------- /commands/tagger/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /commands/pelicula/subs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-mock -------------------------------------------------------------------------------- /utils/exceptions.py: -------------------------------------------------------------------------------- 1 | class AmbroBotException(Exception): 2 | """All handled exceptions inherit from it""" 3 | 4 | pass 5 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | PYTEL=1 2 | RETRO_USERS="[]" 3 | PASTEBIN='' 4 | PASTEBIN_PRIV='' 5 | ADMIN_ID=2 6 | TMDB_KEY=3 7 | DATABASE_URL=4 8 | CABA_SECRET=5 9 | CABA_CLI_ID=7 10 | -------------------------------------------------------------------------------- /requirements.in: -------------------------------------------------------------------------------- 1 | requests 2 | beautifulsoup4 3 | lxml 4 | python-telegram-bot=12.0.0 5 | psycopg2-binary 6 | sqlalchemy 7 | subliminal 8 | numpy 9 | dateparser 10 | pytz 11 | youtube_dl 12 | python-Levenshtein -------------------------------------------------------------------------------- /commands/subte/utils.py: -------------------------------------------------------------------------------- 1 | def format_estado_de_linea(info_de_linea): 2 | linea, estado = info_de_linea 3 | if estado.lower() == 'normal': 4 | estado = '✅' 5 | else: 6 | estado = f'⚠ {estado} ⚠' 7 | return f'{linea} {estado}' 8 | -------------------------------------------------------------------------------- /commands/subte/constants.py: -------------------------------------------------------------------------------- 1 | DELAY_ICONS = '🚧🚥🚸⚠️🛑🚋🚊🚉🚆🚇🚈' 2 | SUBWAY_ICON = '🚆' 3 | SUBWAY_STATUS_OK = '✅ Todos los subtes funcionan con normalidad' 4 | SUBWAY_LINE_OK = '✅ La linea {} funciona con normalidad' 5 | 6 | SUBTE_UPDATES_CRON = 'subte-alerts' 7 | -------------------------------------------------------------------------------- /commands/github/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import CommandHandler 2 | 3 | 4 | def github_repo(bot, update): 5 | update.message.reply_text('You can find my source code @ https://github.com/Ambro17/AmbroBot') 6 | 7 | 8 | github_handler = CommandHandler('github', github_repo) 9 | -------------------------------------------------------------------------------- /commands/hoypido/callback.py: -------------------------------------------------------------------------------- 1 | from commands.hoypido.utils import prettify_food_offers 2 | 3 | 4 | def hoypido_callback(week_menu, requested_day): 5 | """Filter the menu for the requested day from the week_menu""" 6 | return prettify_food_offers(week_menu, int(requested_day)) 7 | -------------------------------------------------------------------------------- /commands/retro/handler.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import CommandHandler 2 | 3 | from commands.retro.commands import retro_add, show_retro_items, expire_retro 4 | 5 | add_retro_item = CommandHandler('retro', retro_add, pass_args=True) 6 | show_retro_details = CommandHandler('retroitems', show_retro_items) 7 | expire_retro_command = CommandHandler('endretro', expire_retro) 8 | -------------------------------------------------------------------------------- /commands/subte/suscribers/constants.py: -------------------------------------------------------------------------------- 1 | LINEAS = ('A', 'B', 'C', 'D', 'E', 'H') 2 | MISSING_LINEA_MESSAGE = ( 3 | "Faltó agregar la linea a la cual querés suscribirte.\nEjemplo: `/suscribe A`.\n" 4 | f"Las lineas disponibles son: {', '.join(LINEAS)}" 5 | ) 6 | UNSUSCRIBED_MESSAGE = ( 7 | "Faltó agregar la linea de la cual querés suscribirte. `/unsuscribe A`" 8 | ) 9 | -------------------------------------------------------------------------------- /commands/yts/constants.py: -------------------------------------------------------------------------------- 1 | # CallbackQuery matchers 2 | import re 3 | 4 | YTS = r'YTS_' 5 | NEXT_YTS = YTS + 'NEXT' 6 | YTS_TORRENT = YTS + 'TORRENT' 7 | YTS_FULL_DESC = YTS + 'DESCRIPTION' 8 | 9 | IMDB_LINK = 'https://www.imdb.com/title/{}' 10 | YT_LINK = 'https://www.youtube.com/watch?v={}' 11 | 12 | MEDIA_CAPTION_LIMIT = 1024 13 | 14 | YTS_REGEX = re.compile(YTS) 15 | -------------------------------------------------------------------------------- /commands/meeting/constants.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | MEETING_FILTER = r'MEETING_' 4 | DAY_T = MEETING_FILTER + '{}' 5 | MEETING_PERIOD = MEETING_FILTER + 'PERIOD_{}' 6 | CANCEL = MEETING_FILTER + 'CANCEL' 7 | 8 | time_delta_map = { 9 | 'Weekly': timedelta(weeks=1), 10 | 'Biweekly': timedelta(weeks=2), 11 | 'Monthly': timedelta(weeks=4), 12 | } 13 | -------------------------------------------------------------------------------- /commands/aproximacion/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | APROXIMACION = r'APROXIMACION' 4 | JACOBI = 'Jacobi' 5 | GAUSS_SEIDEL = 'Gauss Seidel' 6 | 7 | DETALLE = 'Detalle' 8 | OTHER_METHOD = 'Otro' 9 | EXPORT_CSV = 'Exportar' 10 | SALIR = 'Salir' 11 | 12 | EXAMPLE_NOT_DDOM = "1 2 3\n4 5 6\n7 8 9" 13 | EXAMPLE_DDOM_ROW = "5 3 1\n2 6 0\n1 2 4" 14 | EXAMPLE_DDOM_COL = "5 3 3\n2 6 0\n1 2 4" 15 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | # "Activate" venv by make it the first python in PATH 4 | RUN python3 -m venv /opt/venv 5 | ENV PATH="/opt/venv/bin:$PATH" 6 | 7 | # Install requirments in virtualenv 8 | RUN pip install --upgrade pip wheel 9 | COPY requirements.txt requirements.txt 10 | RUN pip install -r requirements.txt 11 | 12 | 13 | WORKDIR /code 14 | COPY . . 15 | 16 | CMD ["python", "main.py"] 17 | -------------------------------------------------------------------------------- /commands/dolar/callback.py: -------------------------------------------------------------------------------- 1 | from commands.dolar.utils import pretty_print_dolar 2 | 3 | 4 | def dolarhoy_callback(banco_data, banco): 5 | """Shows only the info of desired banco from banco_data""" 6 | requested_banco = {k: v for k, v in banco_data.items() if k == banco} 7 | if requested_banco: 8 | return pretty_print_dolar(requested_banco) 9 | elif banco == 'Todos': 10 | return pretty_print_dolar(banco_data) 11 | -------------------------------------------------------------------------------- /commands/subte/suscribers/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from commands.subte.suscribers.db import add_subte_suscriber 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def add_suscriber_to_linea(user_id, name, linea): 8 | try: 9 | add_subte_suscriber(user_id, name, linea) 10 | return True 11 | except Exception: 12 | logger.info("Error saving subte suscriber", exc_info=True) 13 | return False 14 | -------------------------------------------------------------------------------- /commands/dolar_futuro/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import namedtuple 3 | 4 | month_name = { 5 | 1: 'Ene', 6 | 2: 'Feb', 7 | 3: 'Mar', 8 | 4: 'Abr', 9 | 5: 'May', 10 | 6: 'Jun', 11 | 7: 'Jul', 12 | 8: 'Ago', 13 | 9: 'Sep', 14 | 10: 'Oct', 15 | 11: 'Nov', 16 | 12: 'Dic', 17 | } 18 | DOLAR_REGEX = re.compile(r'DLR(\d{2})(\d{4})') # DLRmmYYYY 19 | Contrato = namedtuple('Contrato', ['mes', 'año', 'valor']) 20 | 21 | EMPTY_MESSAGE = '⌛️ No hay info disponible en este momento' 22 | -------------------------------------------------------------------------------- /commands/hoypido/keyboard.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 2 | 3 | from commands.hoypido.utils import day_names 4 | 5 | 6 | def hoypido_keyboard(comidas): 7 | weekday_buttons = [ 8 | [ 9 | Button(day_names[day_int], callback_data=day_int) 10 | for day_int in sorted(comidas) 11 | ], 12 | [ 13 | Button('🥕 Ir a Hoypido', url='https://www.hoypido.com/menu/onapsis.saludable') 14 | ], 15 | ] 16 | return InlineKeyboardMarkup(weekday_buttons) 17 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | build flags="": 2 | docker build -t cuervot . {{flags}} 3 | 4 | setup: 5 | pip install pip-tools 6 | 7 | run: build 8 | docker run -it --rm --env-file .env cuervot 9 | 10 | bash: build 11 | docker run -it --rm --env-file .env cuervot bash 12 | 13 | lockdeps: 14 | pip-compile requirements.in -o requirements.txt --resolver=backtracking 15 | 16 | deploy: 17 | git push dokku master:master 18 | 19 | logs: 20 | ssh dokku@157.230.228.39 logs cuervot --tail 21 | 22 | logsdeploy: 23 | ssh dokku@157.230.228.39 logs:failed cuervot 24 | 25 | envs: 26 | ssh dokku@157.230.228.39 config:show cuervot 27 | 28 | ssh: 29 | ssh root@157.230.228.39 -------------------------------------------------------------------------------- /commands/snippets/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | # Regexps to match hashtags the snippet to be saved. 4 | # Accepts A-Za-z0-9, underscore, hyphen and closing question mark 5 | SAVE_REGEX = re.compile(r'^#(?P[\w\-?]+) +(?P[\s\S]+)') 6 | GET_REGEX = re.compile(r'^@get (?P[\w\-?]+)') 7 | DELETE_REGEX = re.compile(r'^@delete (?P[\w\-?]+)') 8 | 9 | DEFAULT_ERROR_MESSAGE = ( 10 | 'Algo salió mal y no pude guardar tu snippet.\n' 11 | 'Podés intentar más tarde o aceptar que en el mundo reina el caos.' 12 | ) 13 | 14 | DUPLICATE_KEY_MESSAGE = ( 15 | '⛔️ La clave `{}` ya existe en la base de datos.\n' 16 | 'Elegí otra para guardar tu snippet 🙏' 17 | ) 18 | -------------------------------------------------------------------------------- /commands/feriados/constants.py: -------------------------------------------------------------------------------- 1 | meses = ( 2 | 'enero', 3 | 'febrero', 4 | 'marzo', 5 | 'abril', 6 | 'mayo', 7 | 'junio', 8 | 'julio', 9 | 'agosto', 10 | 'septiembre', 11 | 'octubre', 12 | 'noviembre', 13 | 'diciembre', 14 | ) 15 | month_num = {mes: mes_number for mes, mes_number in zip(meses, range(1, 13))} 16 | 17 | month_names = {mes_number: mes for mes, mes_number in month_num.items()} 18 | 19 | # heroku doesn't have es_AR locale 20 | days = ('Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo') 21 | ESP_DAY = {day_num: days[day_num] for day_num in range(7)} 22 | 23 | FERIADOS_URL = 'http://nolaborables.com.ar/api/v2/feriados/{year}' 24 | -------------------------------------------------------------------------------- /commands/meeting/db_operations.py: -------------------------------------------------------------------------------- 1 | from commands.meeting.models import Session, Meeting 2 | from utils.decorators import log_time 3 | 4 | 5 | @log_time 6 | def save_meeting(name, date): 7 | session = Session() 8 | session.add(Meeting(name=name, datetime=date)) 9 | session.commit() 10 | 11 | 12 | @log_time 13 | def get_meetings(): 14 | return Session().query(Meeting).filter_by(expired=False).all() 15 | 16 | 17 | @log_time 18 | def delete_meeting_db(name): 19 | session = Session() 20 | meeting = session.query(Meeting).filter_by(name=name).first() 21 | if meeting is not None: 22 | session.delete(meeting) 23 | session.commit() 24 | return True 25 | 26 | return False 27 | -------------------------------------------------------------------------------- /commands/dolar/keyboards.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 2 | 3 | POPULAR_TRADE_HOUSES = ( 4 | 'Galicia', 5 | 'Frances', 6 | 'Nacion', 7 | 'Santander', 8 | 'Mayorista', 9 | 'Dolar bolsa', 10 | ) 11 | 12 | 13 | def banco_keyboard(cotizaciones): 14 | COLUMNS = 3 15 | buttons = [ 16 | Button(f'{banco}', callback_data=banco) 17 | for banco in cotizaciones 18 | if banco in POPULAR_TRADE_HOUSES 19 | ] 20 | columned_keyboard = [ 21 | buttons[i: i + COLUMNS] for i in range(0, len(buttons), COLUMNS) 22 | ] 23 | columned_keyboard.append([ 24 | Button('💰 Todos', callback_data='Todos') 25 | ]) 26 | return InlineKeyboardMarkup(columned_keyboard) 27 | -------------------------------------------------------------------------------- /commands/pelicula/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | from os.path import join as os_join, dirname, abspath 3 | 4 | PELICULA = r'PELICULA_' 5 | 6 | IMDB = PELICULA + 'IMDB' 7 | YOUTUBE = PELICULA + 'YOUTUBE' 8 | TORRENT = PELICULA + 'TORRENT' 9 | SUBTITLES = PELICULA + 'SUBTITLES' 10 | SINOPSIS = PELICULA + 'SINOPSIS' 11 | 12 | PELICULA_REGEX = re.compile(PELICULA) 13 | 14 | NO_TRAILER_MESSAGE = '💤 No hay trailer para esta pelicula' 15 | 16 | SUBS_DIR = os_join(dirname(abspath(__file__)), 'subs') 17 | 18 | LOADING_GIF = 'CgADBAADrqAAAqEeZAfb4Ot0k2Z7bAI' 19 | cool = 'CgADBAAD46AAAuIaZAeIFKhwDWqvUQI' 20 | acumulapunto = 'CgADBAAD46AAAuIaZAdTT7zJlgxNDQI' 21 | green_loading = 'CgADBAAD-aAAAnUaZAfjftup41BQdAI' 22 | 23 | LOADING_GIFS = [LOADING_GIF, cool, acumulapunto, green_loading] 24 | -------------------------------------------------------------------------------- /commands/posiciones/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import run_async, CommandHandler 2 | 3 | from commands.posiciones.utils import parse_posiciones, prettify_table_posiciones 4 | from utils.decorators import send_typing_action, log_time 5 | from utils.utils import soupify_url 6 | 7 | 8 | @log_time 9 | @send_typing_action 10 | @run_async 11 | def posiciones(bot, update, **kwargs): 12 | soup = soupify_url('http://www.promiedos.com.ar/primera', encoding='ISO-8859-1') 13 | tabla = soup.find('table', {'id': 'posiciones'}) 14 | info = parse_posiciones(tabla, posiciones=kwargs.get('args')) 15 | pretty = prettify_table_posiciones(info) 16 | bot.send_message(chat_id=update.message.chat_id, text=pretty, parse_mode='markdown') 17 | 18 | 19 | posiciones_handler = CommandHandler('posiciones', posiciones, pass_args=True) 20 | -------------------------------------------------------------------------------- /commands/pelicula/keyboard.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 4 | 5 | from commands.pelicula.constants import IMDB, YOUTUBE, TORRENT, SINOPSIS, SUBTITLES 6 | 7 | 8 | @lru_cache(1) 9 | def pelis_keyboard(include_desc=False): 10 | buttons = [ 11 | [ 12 | Button('🎟️ IMDB', callback_data=IMDB), 13 | Button('🎬️ Trailer', callback_data=YOUTUBE), 14 | ], 15 | [ 16 | Button('🍿 Descargar', callback_data=TORRENT), 17 | Button('💬 Subs', callback_data=SUBTITLES), 18 | ], 19 | ] 20 | if include_desc: 21 | sinospsis_row = [Button('📖️ Sinopsis', callback_data=SINOPSIS)] 22 | buttons.insert(0, sinospsis_row) 23 | 24 | return InlineKeyboardMarkup(buttons) 25 | -------------------------------------------------------------------------------- /commands/subte/suscribers/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sqlalchemy import create_engine, Column, Integer, String 3 | from sqlalchemy.ext.declarative import declarative_base 4 | from sqlalchemy.orm import sessionmaker 5 | 6 | Base = declarative_base() 7 | 8 | engine = create_engine(os.environ['DATABASE_URL']) 9 | Session = sessionmaker(bind=engine) 10 | 11 | 12 | class SubteSuscription(Base): 13 | __tablename__ = 'subte_suscription' 14 | 15 | id = Column(Integer, primary_key=True) 16 | 17 | user_id = Column(String) 18 | user_name = Column(String) 19 | linea = Column(String(20)) 20 | 21 | def __repr__(self): 22 | return "SubteSuscription(user_id='%s', name='%s', linea='%s')" % ( 23 | self.user_id, 24 | self.name, 25 | self.linea, 26 | ) 27 | 28 | 29 | Base.metadata.create_all(bind=engine) -------------------------------------------------------------------------------- /commands/cartelera/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import run_async, CommandHandler 2 | 3 | from utils.decorators import send_typing_action, log_time 4 | from utils.utils import soupify_url 5 | 6 | 7 | @log_time 8 | @send_typing_action 9 | @run_async 10 | def cinearg(bot, update): 11 | """Get top 5 Argentina movies""" 12 | CINE_URL = 'https://www.cinesargentinos.com.ar/cartelera' 13 | soup = soupify_url(CINE_URL) 14 | cartelera = soup.find('div', {'class': 'contenidoRankingContainer'}) 15 | listado = [ 16 | (rank, li.text, CINE_URL + li.a['href']) 17 | for rank, li in enumerate(cartelera.div.ol.find_all('li'), 1) 18 | ] 19 | top_5 = '\n'.join(f'[{rank}. {title}]({link})' for rank, title, link in listado[:5]) 20 | bot.send_message(chat_id=update.message.chat_id, text=top_5, parse_mode='markdown') 21 | 22 | 23 | cartelera_handler = CommandHandler('cartelera', cinearg) 24 | -------------------------------------------------------------------------------- /commands/meeting/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sqlalchemy import create_engine, Column, Integer, String, Boolean 3 | from sqlalchemy.dialects.postgresql import TIMESTAMP 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | Base = declarative_base() 8 | 9 | engine = create_engine(os.environ['DATABASE_URL']) 10 | Session = sessionmaker(bind=engine) 11 | 12 | 13 | class Meeting(Base): 14 | __tablename__ = 'meeting' 15 | 16 | id = Column(Integer, primary_key=True) 17 | 18 | name = Column(String) 19 | datetime = Column(TIMESTAMP(timezone=True)) 20 | expired = Column(Boolean, default=False) 21 | 22 | def __repr__(self): 23 | return "" % ( 24 | self.name, 25 | self.datetime, 26 | self.expired, 27 | ) 28 | 29 | 30 | Base.metadata.create_all(bind=engine) -------------------------------------------------------------------------------- /keyboards/keyboards.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 4 | 5 | from commands.yts.constants import ( 6 | NEXT_YTS, 7 | YTS_TORRENT, 8 | IMDB_LINK, 9 | YT_LINK, 10 | YTS_FULL_DESC, 11 | ) 12 | 13 | 14 | def yts_navigator_keyboard(imdb_id=None, yt_trailer=None, show_next=True): 15 | buttons = [ 16 | [Button('📖 Read more', callback_data=YTS_FULL_DESC)], 17 | [ 18 | Button('🍿 Torrent', callback_data=YTS_TORRENT), 19 | Button('🎟️ IMDB', url=IMDB_LINK.format(imdb_id)), 20 | Button('🎬️ Trailer', url=YT_LINK.format(yt_trailer)), # Todo: only add if yt_trailer is not None 21 | ], 22 | ] # Implement Back too 23 | if show_next: 24 | buttons[0].append(Button('Next »', callback_data=NEXT_YTS)) 25 | 26 | return InlineKeyboardMarkup(buttons) 27 | 28 | -------------------------------------------------------------------------------- /commands/hoypido/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import run_async, CommandHandler 2 | 3 | from commands.hoypido.keyboard import hoypido_keyboard 4 | from commands.hoypido.utils import get_comidas, prettify_food_offers 5 | from utils.decorators import send_typing_action, log_time 6 | 7 | 8 | @log_time 9 | @send_typing_action 10 | @run_async 11 | def hoypido(bot, update, chat_data): 12 | comidas = get_comidas() 13 | pretty_comidas = prettify_food_offers(comidas) 14 | 15 | chat_data['context'] = { 16 | 'data': comidas, 17 | 'command': 'hoypido', 18 | 'edit_original_text': True, 19 | } 20 | keyboard = hoypido_keyboard(comidas) 21 | bot.send_message( 22 | update.message.chat_id, 23 | text=pretty_comidas, 24 | reply_markup=keyboard, 25 | parse_mode='markdown', 26 | ) 27 | 28 | 29 | hoypido_handler = CommandHandler('hoypido', hoypido, pass_chat_data=True) 30 | -------------------------------------------------------------------------------- /commands/posiciones/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from utils.utils import normalize, monospace 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def parse_posiciones(tabla, posiciones=None): 9 | posiciones = int(posiciones[0]) if posiciones else 10 10 | LIMIT = 4 11 | # ['#', 'Equipo', 'Pts', 'PJ'] 12 | headers = [th.text for th in tabla.thead.find_all('th')[:LIMIT]] 13 | a = [] 14 | for row in tabla.tbody.find_all('tr')[:posiciones]: 15 | b = [normalize(r.text) for r in row.find_all('td')[:LIMIT]] 16 | a.append(b) 17 | a.insert(0, headers) 18 | return a 19 | 20 | 21 | def prettify_table_posiciones(info): 22 | try: 23 | return monospace( 24 | '\n'.join( 25 | '{:2} | {:12} | {:3} | {:3}'.format(*team_stat) for team_stat in info 26 | ) 27 | ) 28 | except Exception: 29 | logger.error(f"Error Prettying info {info}") 30 | return 'No te entiendo..' 31 | -------------------------------------------------------------------------------- /commands/retro/models.py: -------------------------------------------------------------------------------- 1 | import os 2 | from sqlalchemy import create_engine, Column, Integer, String, Boolean 3 | from sqlalchemy.dialects.postgresql import TIMESTAMP 4 | from sqlalchemy.ext.declarative import declarative_base 5 | from sqlalchemy.orm import sessionmaker 6 | 7 | Base = declarative_base() 8 | 9 | engine = create_engine(os.environ['DATABASE_URL']) 10 | Session = sessionmaker(bind=engine) 11 | 12 | 13 | class RetroItem(Base): 14 | __tablename__ = 'retro_items' 15 | 16 | id = Column(Integer, primary_key=True) 17 | 18 | user = Column(String) 19 | text = Column(String) 20 | datetime = Column(TIMESTAMP(timezone=True)) 21 | expired = Column(Boolean, default=False) 22 | 23 | def __repr__(self): 24 | return "" % ( 25 | self.user, 26 | self.text, 27 | self.datetime, 28 | self.expired, 29 | ) 30 | 31 | 32 | Base.metadata.create_all(bind=engine) -------------------------------------------------------------------------------- /commands/dolar/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import run_async, CommandHandler 2 | 3 | from commands.dolar.keyboards import banco_keyboard 4 | from commands.dolar.utils import get_cotizaciones, pretty_print_dolar 5 | from utils.decorators import send_typing_action, log_time 6 | from utils.utils import soupify_url 7 | 8 | 9 | @log_time 10 | @send_typing_action 11 | @run_async 12 | def dolar_hoy(bot, update, chat_data): 13 | soup = soupify_url("http://www.dolarhoy.com/usd") 14 | data = soup.find_all('table') 15 | 16 | cotiz = get_cotizaciones(data) 17 | pretty_result = pretty_print_dolar(cotiz) 18 | 19 | chat_data['context'] = { 20 | 'data': cotiz, 21 | 'command': 'dolarhoy', 22 | 'edit_original_text': True, 23 | } 24 | keyboard = banco_keyboard(cotiz) 25 | bot.send_message( 26 | update.message.chat_id, 27 | text=pretty_result, 28 | reply_markup=keyboard, 29 | parse_mode='markdown', 30 | ) 31 | 32 | 33 | dolar_handler = CommandHandler('dolar', dolar_hoy, pass_chat_data=True) 34 | -------------------------------------------------------------------------------- /commands/meeting/keyboard.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 2 | 3 | from commands.meeting.constants import DAY_T, CANCEL, MEETING_PERIOD 4 | 5 | 6 | def days_selector_keyboard(): 7 | # Show days and recurrence. Every week, every two weeks, once? 8 | buttons = [ 9 | [ 10 | Button(dia, callback_data=DAY_T.format(dia)) 11 | for dia in ('Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes') 12 | ] 13 | ] 14 | # Add second keyboard with weekly, biweekly and monthly. 15 | return InlineKeyboardMarkup(buttons) 16 | 17 | 18 | def repeat_interval_keyboard(): 19 | buttons = [ 20 | [ 21 | Button('Semanalmente', callback_data=MEETING_PERIOD.format("Weekly")), 22 | Button('Bisemanalmente', callback_data=MEETING_PERIOD.format("Biweekly")), 23 | Button('Mensualmente', callback_data=MEETING_PERIOD.format("Monthly")), 24 | ], 25 | [ 26 | Button('🚫 Cancel', callback_data=CANCEL) 27 | ] 28 | ] 29 | return InlineKeyboardMarkup(buttons) 30 | -------------------------------------------------------------------------------- /commands/start/command.py: -------------------------------------------------------------------------------- 1 | def start(bot, update): 2 | message = ( 3 | "🇦🇷 Hi, i'm Cuervot! \n" 4 | "These are my skills:\n\n" 5 | "/dolar - Cotización del dólar\n" 6 | "/rofex - Cotizacion dólar futuro\n" 7 | "/subte - Estado de subtes CABA\n" 8 | "/pelicula - Buscar detalle de pelicula\n" 9 | "/hoypido - Ver menú de hoypido\n" 10 | "/serie - Descargar capitulos de series por torrent\n" 11 | "/yts - Ver ultimas peliculas de yts\n" 12 | "/feriados - Ver próximos feriados de Argentina\n" 13 | "/snippets - Snippets de codigo/troubleshooting/cualquier cosa\n" 14 | "/partido - Próximo partido de San Lorenzo\n" 15 | "/posiciones - Tabla de posiciones Argenntina\n" 16 | "/cartelera - Peliculas más populares en cartelera\n" 17 | "/aproximar - Calcular la solución del sistema de ecuaciones lineales\n\n" 18 | "If you find any bug or suggestion, you can send it through /feedback\n" 19 | "I'm also open source so you can see how i work with /code" 20 | ) 21 | update.message.reply_text(message, parse_mode='markdown', disable_web_page_preview=True) 22 | -------------------------------------------------------------------------------- /utils/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | IMDB_LINK = 'https://www.imdb.com/title/{}' 4 | IMDB_TT_LINK = 'https://www.imdb.com/title/tt{}' 5 | YT_LINK = 'https://www.youtube.com/watch?v={}' 6 | 7 | COMANDO_DESCONOCIDO = [ 8 | 'No sé qué decirte..', 9 | 'Sabes que no te entiendo..', 10 | 'Todavía no aprendí ese comando', 11 | 'Mmmm no entiendo', 12 | 'No sé cómo interpretar tu pedido', 13 | 'No respondo a ese comando', 14 | 'No conozco a ese comando. Lo escribiste bien?', 15 | 'No entiendo qué querés.', 16 | 'No existe ese comando.', 17 | ] 18 | 19 | VALID_PREFIX = r'(\s|^)' # It is valid if it is the beginning of the string or a space 20 | VALID_SUFFIX = r'(?=\s|$)' # It is valid if space or EOS. (+ lookahead, not consuming) 21 | TICKET_MATCHER = r'(t-?|osp-?)(?P\d{5,})' 22 | 23 | # Ticket ids of 5 or 6 numbers preceded by t|osp||osp- or any casing variant. 24 | TICKET_REGEX = re.compile(f'{VALID_PREFIX}{TICKET_MATCHER}{VALID_SUFFIX}', re.IGNORECASE) 25 | 26 | # Text starting with ~, \c, \code or $ will be monospaced formatted 27 | CODE_PREFIX = re.compile(r'^(~|\\code|\$|\\c) (?P[\s\S]+)') 28 | 29 | # Minute in seconds 30 | MINUTE = 60 31 | 32 | # Buenos aires GMT offset 33 | GMT_BUENOS_AIRES = -3 34 | -------------------------------------------------------------------------------- /commands/subte/suscribers/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from commands.subte.suscribers.models import SubteSuscription, Session 3 | from utils.decorators import log_time 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | @log_time 9 | def add_subte_suscriber(user_id, name, linea): 10 | session = Session() 11 | suscription = SubteSuscription(user_id=user_id, user_name=name, linea=linea) 12 | session.add(suscription) 13 | session.commit() 14 | 15 | 16 | def remove_subte_suscriber(user_id, line): 17 | session = Session() 18 | suscription = session.query(SubteSuscription).filter_by(user_id=user_id, linea=line).first() 19 | if suscription is None: 20 | logger.info("Suscription (%s, %s) does not exist on db", user_id, line) 21 | deleted = False 22 | else: 23 | session.delete(suscription) 24 | session.commit() 25 | logger.info("Suscription (%s, %s) DELETED", user_id, line) 26 | deleted = True 27 | 28 | return deleted 29 | 30 | 31 | @log_time 32 | def get_suscriptors(): 33 | session = Session() 34 | return session.query(SubteSuscription).all() 35 | 36 | 37 | @log_time 38 | def get_suscriptors_by_line(line): 39 | session = Session() 40 | return session.query(SubteSuscription).filter_by(linea=line).all() 41 | -------------------------------------------------------------------------------- /commands/serie/constants.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import namedtuple 3 | 4 | # Callback data constants 5 | SERIE = r'SERIE_' 6 | LATEST_EPISODES = SERIE + 'LATEST_EPISODES' 7 | LOAD_MORE_LATEST = SERIE + 'MORE_LATEST' 8 | LOAD_EPISODES = SERIE + 'LOAD_EPISODES' 9 | GO_BACK_TO_MAIN = SERIE + 'GO_TO_MAIN_RESULT' 10 | SEASON_T = SERIE + 'SEASON_{}' 11 | EPISODE_T = SERIE + 'EPISODE_{}' 12 | SERIE_REGEX = re.compile(SERIE) 13 | 14 | 15 | # Regex to find season and episode data 16 | SEASON_REGEX = re.compile(r'S(\d{1,})E(\d{1,})') # S01E15 17 | ALT_SEASON_REGEX = re.compile(r'(\d{1,})x(\d{1,})') # 1x15 18 | EPISODE_PATTERNS = [SEASON_REGEX, ALT_SEASON_REGEX] 19 | 20 | # Indexes to build Episode from html row 21 | NAME, SIZE, RELEASED, SEEDS = 0, 2, 3, 4 22 | MAGNET, TORRENT = 0, 1 23 | 24 | # Episode representation after parsing eztv web 25 | Episode = namedtuple( 26 | 'Episode', 27 | ['name', 'season', 'episode', 'magnet', 'torrent', 'size', 'released', 'seeds'], 28 | ) 29 | # Episode representation from eztv api 30 | EZTVEpisode = namedtuple( 31 | 'EZTVpisode', ['name', 'season', 'episode', 'torrent', 'size', 'seeds'] 32 | ) 33 | 34 | # eztv api error messages 35 | EZTV_API_ERROR = "EZTV api failed to respond with latest torrents. Try 'Load all episodes' option and look for latest episode." 36 | EZTV_NO_RESULTS = ( 37 | "Eztv api is in beta mode and doesn't have all the series❕\n" 38 | "You can try loading all episodes and manually searching the latest." 39 | ) 40 | -------------------------------------------------------------------------------- /commands/feedback/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import ConversationHandler, CommandHandler, MessageHandler, Filters 2 | 3 | from utils.utils import send_message_to_admin 4 | 5 | SEND_FEEDBACK = 10 6 | 7 | 8 | def default_msg(bot, update): 9 | update.effective_message.reply_text('Feedback message must be text. Let\'s try again with /feedback.') 10 | return ConversationHandler.END 11 | 12 | 13 | def feedback(bot, update, args): 14 | if not args: 15 | update.effective_message.reply_text('Enter the bug/suggestion/feature request', quote=False) 16 | return SEND_FEEDBACK 17 | suggestion = ' '.join(args) 18 | _send_feedback(bot, update, suggestion) 19 | return ConversationHandler.END 20 | 21 | 22 | def send_feedback(bot, update): 23 | suggestion = update.effective_message.text 24 | _send_feedback(bot, update, suggestion) 25 | return ConversationHandler.END 26 | 27 | 28 | def _send_feedback(bot, update, suggestion): 29 | user = update.effective_message.from_user.name 30 | send_message_to_admin(bot, f'💬 Feedback!\n\n{suggestion}\n\nby {user}') 31 | update.effective_message.reply_text('✅ Feedback sent 🗳', quote=False) 32 | 33 | 34 | feedback_receiver = ConversationHandler( 35 | entry_points=[CommandHandler('feedback', feedback, pass_args=True)], 36 | states={ 37 | SEND_FEEDBACK: [MessageHandler(Filters.text, send_feedback)], 38 | }, 39 | fallbacks=[MessageHandler(Filters.all, default_msg)], 40 | allow_reentry=True 41 | ) 42 | -------------------------------------------------------------------------------- /tests/commands/snippets/test_regexps.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from commands.snippets.constants import SAVE_REGEX, GET_REGEX, DELETE_REGEX 4 | 5 | 6 | @pytest.mark.parametrize( 7 | 'input, match', 8 | [ 9 | ('#casobase ', True), 10 | ('#con_underscore ', True), 11 | ('#con-guion ', True), 12 | ('#valepregunta? ', True), 13 | ('#C0nNum3r0s? ', True), 14 | ('#signo_apertura_¿ ', False), 15 | ('#conpunto. ', False), 16 | ('#concoma, ', False), 17 | ('#', False), 18 | ('# ', False), 19 | ('# agaegea', False), 20 | ], 21 | ) 22 | def test_save_regex(input, match): 23 | assert bool(SAVE_REGEX.match(input)) is match 24 | 25 | 26 | @pytest.mark.parametrize( 27 | 'input, match', 28 | [ 29 | ('@get casobase', True), 30 | ('@get caso_base?---', True), 31 | ('@get 123_caso-base?', True), 32 | ('@get ¿casobase', False), 33 | ('@get *', False), 34 | ], 35 | ) 36 | def test_get_regex_accepts_all_saved_keys(input, match): 37 | assert bool(GET_REGEX.match(input)) is match 38 | 39 | 40 | @pytest.mark.parametrize( 41 | 'input, match', 42 | [ 43 | ('@delete casobase', True), 44 | ('@delete 0123_-_???caso_base?---', True), 45 | ('@delete *', False), 46 | ('@delete', False), 47 | ], 48 | ) 49 | def test_delete_regex_accepts_all_saved_keys(input, match): 50 | assert bool(DELETE_REGEX.match(input)) is match 51 | -------------------------------------------------------------------------------- /commands/dolar_futuro/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import run_async, CommandHandler 2 | 3 | from commands.dolar_futuro.constants import DOLAR_REGEX, Contrato, month_name, EMPTY_MESSAGE 4 | from utils.decorators import send_typing_action, log_time 5 | from utils.utils import soupify_url, monospace 6 | 7 | 8 | @log_time 9 | @send_typing_action 10 | @run_async 11 | def rofex(bot, update): 12 | """Print dolar futuro contracts.""" 13 | rofex_data = get_rofex() 14 | contratos = prettify_rofex(rofex_data) 15 | update.message.reply_text(contratos, parse_mode='markdown') 16 | 17 | 18 | def get_rofex(): 19 | try: 20 | soup = soupify_url('https://www.rofex.com.ar/', verify=False) 21 | except TimeoutError: 22 | return None 23 | 24 | table = soup.find('table', class_='table-rofex') 25 | cotizaciones = table.find_all('tr')[1:] # Exclude header 26 | contratos = [] 27 | 28 | for cotizacion in cotizaciones: 29 | contrato, valor, _, variacion, var_porc = cotizacion.find_all('td') 30 | month, year = DOLAR_REGEX.match(contrato.text).groups() 31 | contratos.append(Contrato(int(month), year, valor.text)) 32 | 33 | return contratos 34 | 35 | 36 | def prettify_rofex(contratos): 37 | values = '\n'.join( 38 | f"{month_name[month]} {year} | {value[:5]}" for month, year, value in contratos 39 | ) 40 | header = ' Dólar | Valor\n' 41 | return monospace(header + values) if contratos is not None else EMPTY_MESSAGE 42 | 43 | 44 | dolar_futuro_handler = CommandHandler('rofex', rofex) 45 | -------------------------------------------------------------------------------- /commands/meeting/command.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from telegram.ext import run_async, CommandHandler 4 | 5 | from commands.meeting.db_operations import get_meetings, delete_meeting_db 6 | from utils.decorators import group_only, log_time, send_typing_action 7 | 8 | 9 | GMT_BUENOS_AIRES = -3 10 | 11 | @log_time 12 | @send_typing_action 13 | @run_async 14 | @group_only 15 | def show_meetings(bot, update): 16 | meetings = get_meetings() 17 | if meetings: 18 | update.message.reply_text( 19 | '\n'.join( 20 | f"{meet.name} | {_localize_time(meet.datetime)}" 21 | for meet in meetings 22 | ) 23 | ) 24 | else: 25 | update.message.reply_text('📋 No hay ninguna meeting guardada todavía') 26 | 27 | 28 | def _localize_time(date): 29 | # Turns UTC time into buenos aires time. 30 | date = date + timedelta(hours=GMT_BUENOS_AIRES) 31 | return date.strftime('%A %d/%m %H:%M').capitalize() 32 | 33 | 34 | @log_time 35 | @send_typing_action 36 | @run_async 37 | @group_only 38 | def delete_meeting(bot, update, args): 39 | if not args: 40 | update.message.reply_text('Tenés que poner el nombre de la reunión a borrar') 41 | return 42 | 43 | name = ' '.join(args) 44 | deleted = delete_meeting_db(name) 45 | if deleted: 46 | update.message.reply_text(f'Reunión `{name}` borrada', parse_mode='markdown') 47 | else: 48 | update.message.reply_text('No existe reunión bajo ese nombre') 49 | 50 | 51 | show_meetings_handler = CommandHandler('meetings', show_meetings) 52 | delete_meeting_handler = CommandHandler('delmeeting', delete_meeting, pass_args=True) 53 | -------------------------------------------------------------------------------- /commands/feriados/command.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone, timedelta 2 | import logging 3 | 4 | from telegram.ext import CommandHandler 5 | 6 | from commands.feriados.utils import ( 7 | get_feriados, 8 | prettify_feriados, 9 | filter_past_feriados, 10 | next_feriado_message, 11 | read_limit_from_args, 12 | ) 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | 17 | def feriadosarg(bot, update, args): 18 | limit = read_limit_from_args(args) 19 | today = datetime.now(tz=timezone(timedelta(hours=-3))) 20 | following_feriados = _get_next_feriados(today) 21 | if following_feriados: 22 | header_msg = next_feriado_message(today, next(following_feriados)) 23 | all_feriados = prettify_feriados(following_feriados, limit=limit) 24 | msg = '\n'.join([header_msg, all_feriados]) 25 | else: 26 | msg = 'No hay más feriados este año' 27 | 28 | update.message.reply_text(msg, parse_mode='markdown') 29 | 30 | 31 | def _get_next_feriados(today): 32 | feriados = get_feriados(today.year) 33 | if not feriados: 34 | return [] 35 | 36 | return filter_past_feriados(today, feriados) 37 | 38 | 39 | def next_feriado(bot, update): 40 | today = datetime.now(tz=timezone(timedelta(hours=-3))) 41 | following_feriados = _get_next_feriados(today) 42 | if following_feriados: 43 | msg = next_feriado_message(today, next(following_feriados)) 44 | else: 45 | msg = 'No hay más feriados este año' 46 | 47 | update.message.reply_text(msg, parse_mode='markdown') 48 | 49 | 50 | feriados_handler = CommandHandler('feriados', feriadosarg, pass_args=True) 51 | proximo_feriado_handler = CommandHandler('feriado', next_feriado) 52 | -------------------------------------------------------------------------------- /commands/yts/command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import requests 4 | from telegram.ext import run_async, CommandHandler 5 | 6 | from commands.yts.utils import get_minimal_movie, prettify_yts_movie 7 | from utils.decorators import send_typing_action, log_time 8 | from keyboards.keyboards import yts_navigator_keyboard 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | @log_time 14 | @send_typing_action 15 | @run_async 16 | def yts(bot, update, chat_data): 17 | try: 18 | r = requests.get('https://yts.am/api/v2/list_movies.json', params={'limit': 50}) 19 | except requests.exceptions.ConnectionError: 20 | update.message.reply_text("📡 La api de yts está caida. Intentá más tarde") 21 | return 22 | if r.status_code != 200: 23 | logger.info(f"Yts api down. {r.status_code}") 24 | return None 25 | try: 26 | movies = r.json()['data']['movies'] 27 | except KeyError: 28 | logger.info( 29 | f'Response has no moives {r.url} {r.status_code} {r.reason} {r.json()}' 30 | ) 31 | return None 32 | 33 | # Build context based on the imdb_id 34 | chat_data['context'] = { 35 | 'data': movies, 36 | 'movie_number': 0, 37 | 'movie_count': len(movies), 38 | 'command': 'yts', 39 | 'edit_original_text': True, 40 | } 41 | title, synopsis, rating, imdb, yt_trailer, image = get_minimal_movie(movies[0]) 42 | movie_desc = prettify_yts_movie(title, synopsis, rating) 43 | yts_navigator = yts_navigator_keyboard(imdb_id=imdb, yt_trailer=yt_trailer) 44 | bot.send_photo( 45 | chat_id=update.message.chat_id, 46 | photo=image, 47 | caption=movie_desc, 48 | reply_markup=yts_navigator, 49 | ) 50 | 51 | 52 | yts_handler = CommandHandler('yts', yts, pass_chat_data=True) 53 | -------------------------------------------------------------------------------- /commands/dolar/utils.py: -------------------------------------------------------------------------------- 1 | import unicodedata 2 | from collections import defaultdict 3 | 4 | from utils.utils import monospace, normalize 5 | 6 | 7 | def get_cotizaciones(response_soup): 8 | """Returns a dict of cotizaciones with banco as keys and exchange rate as value. 9 | 10 | { 11 | "Banco Nación": { 12 | "Compra": "30.00", 13 | "Venta": "32.00", 14 | }, 15 | "Banco Galicia": { 16 | "Compra": "31.00", 17 | "Venta": "33.00", 18 | } 19 | } 20 | 21 | """ 22 | cotizaciones = defaultdict(dict) 23 | for table in response_soup: 24 | # Get cotizaciones 25 | for row_cotizacion in table.tbody.find_all('tr'): 26 | banco, compra, venta = ( 27 | item.get_text() for item in row_cotizacion.find_all('td') 28 | ) 29 | banco_raw = banco.replace('Banco ', '') 30 | banco = _normalize_name(banco_raw) 31 | cotizaciones[banco]['compra'] = compra 32 | cotizaciones[banco]['venta'] = venta 33 | 34 | return cotizaciones 35 | 36 | 37 | def _normalize_name(banco_name): 38 | """Normalize a single tag: remove non valid chars, lower case all. - Credits to @eduzen""" 39 | value = unicodedata.normalize("NFKD", banco_name) 40 | value = value.encode("ascii", "ignore").decode("utf-8") 41 | return value.capitalize() 42 | 43 | 44 | def pretty_print_dolar(cotizaciones, limit=7): 45 | """Returns dolar rates separated by newlines and with code markdown syntax. 46 | ``` 47 | Banco Nacion | $30.00 | $40.00 48 | Banco Galicia | $30.00 | $40.00 49 | ... 50 | ``` 51 | """ 52 | return monospace( 53 | '\n'.join( 54 | "{:8} | {:7} | {:7}".format( 55 | normalize(banco, limit), valor['compra'], valor['venta'] 56 | ) 57 | for banco, valor in cotizaciones.items() 58 | ) 59 | ) 60 | -------------------------------------------------------------------------------- /tests/commands/feriados/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from commands.feriados.utils import feriados_from_string 3 | 4 | one_day = '1. Año nuevo. ' 5 | two_days = '12 y 13. Carnaval. ' 6 | other_two_days = '30 y 31. Pascuas Judías. ' 7 | four_days = '1, 5, 6 y 7. Pascuas Judías. ' 8 | extra_nums_on_the_end = ( 9 | '20. Paso a la Inmortalidad del Gral. José de San Martín (17/8). ' 10 | ) 11 | yet_another_example = '9, 10 y 11. Año Nuevo Judío. Día no laborable' 12 | 13 | 14 | @pytest.mark.parametrize( 15 | 'feriado_desc, expected_feriados', 16 | [ 17 | (one_day, {1: ('Año nuevo.', 'tipo_feriado')}), 18 | ( 19 | two_days, 20 | {12: ('Carnaval.', 'tipo_feriado'), 13: ('Carnaval.', 'tipo_feriado')}, 21 | ), 22 | ( 23 | four_days, 24 | { 25 | 1: ('Pascuas Judías.', 'tipo_feriado'), 26 | 5: ('Pascuas Judías.', 'tipo_feriado'), 27 | 6: ('Pascuas Judías.', 'tipo_feriado'), 28 | 7: ('Pascuas Judías.', 'tipo_feriado'), 29 | }, 30 | ), 31 | ( 32 | extra_nums_on_the_end, 33 | { 34 | 20: ( 35 | 'Paso a la Inmortalidad del Gral. José de San Martín (17/8).', 36 | 'tipo_feriado', 37 | ) 38 | }, 39 | ), 40 | ( 41 | yet_another_example, 42 | { 43 | 9: ('Año Nuevo Judío. Día no laborable', 'tipo_feriado'), 44 | 10: ('Año Nuevo Judío. Día no laborable', 'tipo_feriado'), 45 | 11: ('Año Nuevo Judío. Día no laborable', 'tipo_feriado'), 46 | }, 47 | ), 48 | ], 49 | ) 50 | def test_parse_days(feriado_desc, expected_feriados): 51 | assert feriados_from_string(feriado_desc, 'tipo_feriado') == expected_feriados 52 | -------------------------------------------------------------------------------- /commands/aproximacion/conversation_handler.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import ( 2 | ConversationHandler, 3 | CommandHandler, 4 | MessageHandler, 5 | CallbackQueryHandler, 6 | RegexHandler, 7 | Filters, 8 | ) 9 | 10 | from commands.aproximacion.state_handlers import ( 11 | ingresar_matriz, 12 | read_matriz, 13 | read_coef_matrix_and_choose_method, 14 | solve_method, 15 | solve_method_by_text, 16 | read_method_parameters, 17 | calculate, 18 | details, 19 | cancel, 20 | default, 21 | ) 22 | from commands.aproximacion.utils import number_callback 23 | 24 | 25 | READ_MATRIX_A, READ_MATRIX_B, SOLVE_METHOD, METHOD_PARAMETERS, APROXIMAR, DETAILS = range(6) 26 | 27 | 28 | msup_conversation = ConversationHandler( 29 | entry_points=[CommandHandler('aproximar', ingresar_matriz)], 30 | states={ 31 | READ_MATRIX_A: [MessageHandler(Filters.text, read_matriz, pass_chat_data=True)], 32 | READ_MATRIX_B: [ 33 | MessageHandler( 34 | Filters.text, read_coef_matrix_and_choose_method, pass_chat_data=True 35 | ), 36 | # If the user clicks on the matrix numbers stop the loading icon. 37 | CallbackQueryHandler(number_callback), 38 | ], 39 | SOLVE_METHOD: [ 40 | CallbackQueryHandler(solve_method, pass_chat_data=True), 41 | RegexHandler( 42 | r'(jacobi|gauss|j|g)', 43 | solve_method_by_text, 44 | pass_chat_data=True, 45 | pass_groups=True, 46 | ), 47 | ], 48 | METHOD_PARAMETERS: [ 49 | MessageHandler(Filters.text, read_method_parameters, pass_chat_data=True) 50 | ], 51 | APROXIMAR: [CallbackQueryHandler(calculate, pass_chat_data=True)], 52 | DETAILS: [CallbackQueryHandler(details, pass_chat_data=True)], 53 | }, 54 | fallbacks=[CommandHandler('cancel', cancel), CallbackQueryHandler(default)], 55 | ) 56 | -------------------------------------------------------------------------------- /commands/hoypido/utils.py: -------------------------------------------------------------------------------- 1 | # Credits to @yromero 2 | 3 | import logging 4 | from datetime import datetime 5 | 6 | import requests 7 | 8 | from utils.utils import monospace 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | ONAPSIS_SALUDABLE = "https://api.hoypido.com/company/326/menus" 13 | ONAPSIS_PAGO = "https://api.hoypido.com/company/327/menus" 14 | 15 | MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY = range(0, 7) 16 | 17 | day_names = { 18 | MONDAY: 'Lunes', 19 | TUESDAY: 'Martes', 20 | WEDNESDAY: 'Miércoles', 21 | THURSDAY: 'Jueves', 22 | FRIDAY: 'Viernes', 23 | SATURDAY: 'Sábado', 24 | SUNDAY: 'Domingo', 25 | } 26 | 27 | 28 | def get_comidas(): 29 | menu_por_dia = {} 30 | response = requests.get(ONAPSIS_SALUDABLE, timeout=2) 31 | for day_offer in response.json(): 32 | food_offers = sorted( 33 | [food_offer["name"] for food_offer in day_offer['options']] 34 | ) 35 | date = datetime.strptime(day_offer["active_date"], "%Y-%m-%dT%H:%M:%S") 36 | menu_por_dia[date.weekday()] = food_offers 37 | 38 | return menu_por_dia 39 | 40 | 41 | def prettify_food_offers(menu_por_dia, day=None): 42 | today = datetime.today().weekday() 43 | 44 | if day is None: 45 | day = MONDAY if today in (SATURDAY, SUNDAY) else today 46 | 47 | try: 48 | food_offers = menu_por_dia[day] 49 | except KeyError: 50 | # If you ask on tuesday at night, only wednesday food will be retrieved. 51 | logger.info( 52 | "Menu for today not available. Showing next day's menu. %s", menu_por_dia 53 | ) 54 | day = next((key for key in sorted(menu_por_dia) if key > day), None) 55 | food_offers = menu_por_dia.get(day) 56 | 57 | if food_offers: 58 | header = [f"\t\t\t\t\tMenú del {day_names[day]}"] 59 | msg = monospace('\n'.join(header + food_offers + ['⚡️ by Yona'])) 60 | else: 61 | msg = 'No hay información sobre el menú 🍽' 62 | 63 | return msg 64 | -------------------------------------------------------------------------------- /commands/misc/commands.py: -------------------------------------------------------------------------------- 1 | import os 2 | import random 3 | import re 4 | 5 | from telegram.ext import run_async, RegexHandler, MessageHandler, Filters, CommandHandler 6 | 7 | from utils.utils import monospace 8 | from utils.constants import COMANDO_DESCONOCIDO, TICKET_REGEX, CODE_PREFIX 9 | from utils.decorators import send_typing_action, log_time 10 | 11 | 12 | @log_time 13 | @send_typing_action 14 | @run_async 15 | def format_code(bot, update, **kwargs): 16 | """Format text as code if it starts with $, ~, \c or \code.""" 17 | code = kwargs.get('groupdict').get('code') 18 | if code: 19 | bot.send_message( 20 | chat_id=update.message.chat_id, text=monospace(code), parse_mode='markdown' 21 | ) 22 | 23 | 24 | @send_typing_action 25 | @run_async 26 | def link_ticket(bot, update): 27 | """Given a ticket id, return the url.""" 28 | jira_base = os.environ['jira'] 29 | ticket_links = '\n'.join( 30 | f"» {jira_base.format(match.group('ticket'))}" 31 | for match in re.finditer(TICKET_REGEX, update.message.text) 32 | ) 33 | 34 | update.message.reply_text(ticket_links, quote=False) 35 | 36 | 37 | @send_typing_action 38 | @run_async 39 | def default(bot, update): 40 | """If a user sends an unknown command, answer accordingly""" 41 | bot.send_message( 42 | chat_id=update.message.chat_id, text=random.choice(COMANDO_DESCONOCIDO) 43 | ) 44 | 45 | 46 | @send_typing_action 47 | @run_async 48 | def code(bot, update): 49 | """If a user sends an unknown command, answer accordingly""" 50 | REPO = 'https://github.com/Ambro17/AmbroBot' 51 | msg = ( 52 | f"Here you can see my internals: {REPO}\n" 53 | "Don't forget to give it a ⭐️ if you like it!" 54 | ) 55 | update.message.reply_text(msg, disable_web_page_preview=True) 56 | 57 | 58 | show_source = CommandHandler('code', code) 59 | code_handler = RegexHandler(CODE_PREFIX, format_code, pass_groupdict=True) 60 | tickets_handler = MessageHandler(Filters.regex(TICKET_REGEX), link_ticket) 61 | generic_handler = MessageHandler(Filters.command, default) 62 | -------------------------------------------------------------------------------- /commands/serie/keyboard.py: -------------------------------------------------------------------------------- 1 | from functools import lru_cache 2 | 3 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 4 | 5 | from commands.serie.constants import ( 6 | GO_BACK_TO_MAIN, 7 | SEASON_T, 8 | EPISODE_T, 9 | LATEST_EPISODES, 10 | LOAD_EPISODES, 11 | LOAD_MORE_LATEST, 12 | ) 13 | from utils.constants import IMDB_TT_LINK 14 | 15 | GO_BACK_BUTTON_ROW = [Button('« Back to Main', callback_data=GO_BACK_TO_MAIN)] 16 | 17 | 18 | @lru_cache(1) 19 | def serie_main_keyboard(imdb_id): 20 | buttons = [ 21 | [ 22 | Button('Latest episodes', callback_data=LATEST_EPISODES), 23 | Button('Load all episodes', callback_data=LOAD_EPISODES), 24 | ], 25 | [Button('🎟️ IMDB', url=IMDB_TT_LINK.format(imdb_id))], 26 | ] 27 | return InlineKeyboardMarkup(buttons) 28 | 29 | 30 | def serie_go_back_keyboard(): 31 | return InlineKeyboardMarkup([GO_BACK_BUTTON_ROW]) 32 | 33 | 34 | def serie_load_more_latest_episodes_keyboard(): 35 | buttons = [ 36 | [Button('Load more..', callback_data=LOAD_MORE_LATEST)], 37 | GO_BACK_BUTTON_ROW, 38 | ] 39 | return InlineKeyboardMarkup(buttons) 40 | 41 | 42 | def serie_season_keyboard(seasons): 43 | COLUMNS = 2 44 | buttons = [ 45 | Button(f'Season {season}', callback_data=SEASON_T.format(season)) 46 | for season, episodes in sorted(seasons.items()) 47 | ] 48 | columned_keyboard = [ 49 | buttons[i: i + COLUMNS] for i in range(0, len(buttons), COLUMNS) 50 | ] 51 | columned_keyboard.append(GO_BACK_BUTTON_ROW) 52 | 53 | return InlineKeyboardMarkup(columned_keyboard) 54 | 55 | 56 | def serie_episodes_keyboards(episodes_dict): 57 | COLUMNS = 5 58 | buttons = [ 59 | Button(f'Ep {ep_number}', callback_data=EPISODE_T.format(ep_number)) 60 | for ep_number, episode in sorted(episodes_dict.items()) 61 | ] 62 | columned_keyboard = [ 63 | buttons[i: i + COLUMNS] for i in range(0, len(buttons), COLUMNS) 64 | ] 65 | columned_keyboard.append(GO_BACK_BUTTON_ROW) 66 | 67 | return InlineKeyboardMarkup(columned_keyboard) 68 | -------------------------------------------------------------------------------- /commands/subte/command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from telegram.ext import run_async, CommandHandler 4 | 5 | from commands.subte.constants import SUBTE_UPDATES_CRON, SUBWAY_STATUS_OK 6 | from commands.subte.updates.alerts import check_update 7 | from commands.subte.updates.utils import prettify_updates 8 | from utils.constants import MINUTE 9 | from utils.decorators import send_typing_action, log_time, admin_only, handle_empty_arg 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @log_time 15 | @send_typing_action 16 | @run_async 17 | def subte(bot, update): 18 | """Estado de las lineas de subte, premetro y urquiza.""" 19 | NO_PROBLEMS = {} 20 | try: 21 | updates = check_update() 22 | except Exception: 23 | msg = 'Error checking updates. You can check status here https://www.metrovias.com.ar/' 24 | update.message.reply_text(msg) 25 | return 26 | 27 | if updates is None: 28 | msg = 'API did not respond with status 200,\n. You can check subte status here https://www.metrovias.com.ar/' 29 | elif updates == NO_PROBLEMS: 30 | msg = SUBWAY_STATUS_OK 31 | else: 32 | msg = prettify_updates(updates) 33 | 34 | update.message.reply_text(msg) 35 | 36 | 37 | 38 | @admin_only 39 | @handle_empty_arg(required_params=('args',), error_message='Missing required frequency to set for the updates') 40 | def modify_freq(bot, update, job_queue, args): 41 | """Modify subte updates cron tu run every x minutes""" 42 | minutes = args[0] 43 | subte_cron = job_queue.get_jobs_by_name(SUBTE_UPDATES_CRON) 44 | try: 45 | minute_seconds = float(minutes) * MINUTE 46 | subte_cron[0].interval = minute_seconds 47 | msg = f'Subte updates cron frequency set to {minutes} minutes. Seconds ({minute_seconds})' 48 | except ValueError: 49 | msg = f'Frequency must be an int or a float.' 50 | except IndexError: 51 | msg = f'No job found with name {SUBTE_UPDATES_CRON}' 52 | 53 | update.message.reply_text(msg) 54 | logger.info(msg) 55 | 56 | 57 | subte_handler = CommandHandler('subte', subte) 58 | modify_subte_freq = CommandHandler('setsubfreq', modify_freq, pass_job_queue=True, pass_args=True) 59 | -------------------------------------------------------------------------------- /commands/aproximacion/keyboard.py: -------------------------------------------------------------------------------- 1 | from telegram import InlineKeyboardMarkup, InlineKeyboardButton as Button 2 | 3 | from commands.aproximacion.constants import ( 4 | JACOBI, 5 | GAUSS_SEIDEL, 6 | DETALLE, 7 | EXPORT_CSV, 8 | OTHER_METHOD, 9 | SALIR, 10 | ) 11 | 12 | 13 | def equations_matrix_markup(a_matrix, b_matrix): 14 | COLUMNS = len(a_matrix[0]) 15 | A_buttons = [ 16 | Button(f'{num}', callback_data='a') 17 | for row in a_matrix 18 | for num in row 19 | ] 20 | columned_keyboard = [ 21 | A_buttons[i: i + COLUMNS] for i in range(0, len(A_buttons), COLUMNS) 22 | ] 23 | # add b coef with a space 24 | for row, b_value in zip(columned_keyboard, b_matrix): 25 | row.append(Button('=', callback_data='empty')) 26 | row.append(Button(f'{b_value}', callback_data='b_value')) 27 | 28 | # Append resolution methods 29 | jacobi = Button('🤓 Jacobi', callback_data=JACOBI) 30 | gauss_seidel = Button('🔢 Gauss Seidel', callback_data=GAUSS_SEIDEL) 31 | method_selector = [jacobi, gauss_seidel] 32 | 33 | columned_keyboard.append(method_selector) 34 | 35 | return InlineKeyboardMarkup(columned_keyboard) 36 | 37 | 38 | def see_details_or_aproximate_by_other(): 39 | buttons = [ 40 | [ 41 | Button('🔍 Detalle', callback_data=DETALLE), 42 | Button('🖇 Exportar', callback_data=EXPORT_CSV), 43 | ], 44 | [Button('🔁 Cambiar Método ', callback_data=OTHER_METHOD)], 45 | [Button('🚪 Salir', callback_data=SALIR)], 46 | ] 47 | return InlineKeyboardMarkup(buttons) 48 | 49 | 50 | def show_matrix_markup(matrix): 51 | COLUMNS = len(matrix[0]) 52 | buttons = [ 53 | Button(f'{num}', callback_data='a') 54 | for row in matrix 55 | for num in row 56 | ] 57 | columned_keyboard = [ 58 | buttons[i: i + COLUMNS] for i in range(0, len(buttons), COLUMNS) 59 | ] 60 | return InlineKeyboardMarkup(columned_keyboard) 61 | 62 | 63 | def aproximar_o_cancelar(): 64 | buttons = [ 65 | [ 66 | Button('✅ Calcular', callback_data='Calcular'), 67 | Button('🚫 Cancelar', callback_data='/cancel'), 68 | ] 69 | ] 70 | return InlineKeyboardMarkup(buttons) 71 | -------------------------------------------------------------------------------- /commands/pelicula/command.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from telegram.ext import run_async, CommandHandler 3 | 4 | from commands.pelicula.keyboard import pelis_keyboard 5 | from commands.pelicula.utils import ( 6 | request_movie, 7 | get_basic_info, 8 | prettify_basic_movie_info, 9 | ) 10 | from utils.decorators import send_typing_action, log_time 11 | 12 | 13 | @log_time 14 | @send_typing_action 15 | @run_async 16 | def buscar_peli(bot, update, chat_data, **kwargs): 17 | pelicula = kwargs.get('args') 18 | if not pelicula: 19 | bot.send_message( 20 | chat_id=update.message.chat_id, 21 | text='Necesito que me pases una pelicula. `/pelicula `', # Todo: Add deeplink with example 22 | parse_mode='markdown', 23 | ) 24 | return 25 | 26 | try: 27 | pelicula_query = ' '.join(pelicula) 28 | movie = request_movie(pelicula_query) 29 | if not movie: 30 | bot.send_message( 31 | chat_id=update.message.chat_id, 32 | text='No encontré info sobre %s' % pelicula_query, 33 | ) 34 | return 35 | 36 | movie_info = get_basic_info(movie) 37 | # Give context to button handlers 38 | chat_data['context'] = { 39 | 'data': {'movie': movie, 'movie_basic': movie_info}, 40 | 'command': 'pelicula', 41 | 'edit_original_text': True, 42 | } 43 | 44 | movie_details, poster = prettify_basic_movie_info(movie_info) 45 | if poster: 46 | bot.send_photo(chat_id=update.message.chat_id, photo=poster) 47 | 48 | update.message.reply_text( 49 | text=movie_details, 50 | reply_markup=pelis_keyboard(), 51 | parse_mode='markdown', 52 | disable_web_page_preview=True, 53 | quote=False, 54 | ) 55 | except requests.exceptions.ConnectionError: 56 | bot.send_message( 57 | chat_id=update.message.chat_id, 58 | text='Estoy descansando ahora, probá después de la siesta', 59 | parse_mode='markdown', 60 | ) 61 | 62 | 63 | pelis = CommandHandler('pelicula', buscar_peli, pass_args=True, pass_chat_data=True) 64 | pelis_alt = CommandHandler('película', buscar_peli, pass_args=True, pass_chat_data=True) 65 | -------------------------------------------------------------------------------- /commands/register/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | import telegram 5 | from sqlalchemy import create_engine, Column, Integer, String, Boolean 6 | from sqlalchemy.ext.declarative import declarative_base 7 | from sqlalchemy.orm import sessionmaker 8 | 9 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 10 | level=logging.INFO) 11 | logger = logging.getLogger(__name__) 12 | 13 | Base = declarative_base() 14 | 15 | engine = create_engine(os.environ['DATABASE_URL']) 16 | Session = sessionmaker(bind=engine) 17 | 18 | 19 | class User(Base): 20 | __tablename__ = 'users' 21 | 22 | id = Column(Integer, primary_key=True) 23 | first_name = Column(String, nullable=False) 24 | last_name = Column(String, nullable=True, default=None) 25 | username = Column(String, nullable=True, default=None) 26 | authorized = Column(Boolean, default=True) 27 | 28 | def __repr__(self): 29 | return "" % ( 30 | self.first_name, self.last_name, self.username, self.id, 31 | ) 32 | 33 | def __str__(self): 34 | if self.username: 35 | return f"@{self.username}" 36 | elif self.first_name and self.last_name: 37 | return f"{self.first_name} {self.last_name}" 38 | else: 39 | return self.first_name 40 | 41 | 42 | def add_user(user_dict): 43 | """Saves BotUser into db""" 44 | session = Session() 45 | user_to_add = User(**user_dict) 46 | session.add(user_to_add) 47 | session.commit() 48 | 49 | 50 | def delete_user(user_id): 51 | """Soft delete user""" 52 | session = Session() 53 | user_to_delete = session.query(User).filter(User.id==user_id).first() 54 | if user_to_delete is None: 55 | logger.info("User %s does not exist on db", user_id) 56 | else: 57 | session.delete(user_to_delete) 58 | logger.info("User %s DELETED", user_id) 59 | 60 | session.commit() 61 | 62 | def _get_users(): 63 | session = Session() 64 | return session.query(User).all() 65 | 66 | def authorized_user(user_id): 67 | """Returns None if user does not exist, user if it exists""" 68 | session = Session() 69 | return session.query(User).filter(User.id==user_id).first() 70 | 71 | 72 | Base.metadata.create_all(bind=engine) -------------------------------------------------------------------------------- /tests/utils/test_decorators.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from utils.decorators import handle_empty_arg 4 | 5 | 6 | @pytest.fixture() 7 | def func(): 8 | def helper_func(bot, update, a, b): 9 | return bot, update, a, b 10 | 11 | return helper_func 12 | 13 | 14 | @pytest.fixture() 15 | def fake_func_with_kwargs(): 16 | def helper_func(bot, update, a, b, c=3, d=4): 17 | return bot, update, a, b 18 | 19 | return helper_func 20 | 21 | 22 | @pytest.fixture() 23 | def fake_func_required_kwarg(bot, update, my_kwarg=1): 24 | return bot, update, my_kwarg 25 | 26 | 27 | @pytest.fixture() 28 | def update_object(mocker): 29 | return mocker.MagicMock(name='update') 30 | 31 | 32 | @pytest.fixture() 33 | def bot_object(mocker): 34 | return mocker.MagicMock(name='bot') 35 | 36 | 37 | EMPTY = '' 38 | 39 | 40 | @pytest.mark.parametrize('func', [func(), fake_func_with_kwargs()]) 41 | def test_handle_empty_arg_decorator(bot_object, update_object, func): 42 | # Pre-Check that function just returns its args 43 | assert func(bot_object, update_object, 'a', 'b') == (bot_object, update_object, 'a', 'b') 44 | 45 | # Create decorator with custom values and decorate function to output message if required_param is empty 46 | decorated_func = handle_empty_arg( 47 | required_params=('a',), error_message='falto un arg', parse_mode='some_parse_mode' 48 | )(func) 49 | 50 | # Execute decorated func 51 | result = decorated_func(bot_object, update_object, EMPTY, 'b') 52 | 53 | # Assert function was not executed and instead reply_text was called 54 | assert update_object.effective_message.reply_text.call_count == 1 55 | update_object.effective_message.reply_text.assert_called_once_with( 56 | 'falto un arg', parse_mode='some_parse_mode' 57 | ) 58 | 59 | # Check that function *is* called if required argument is not empty 60 | second_result = decorated_func(bot_object, update_object, 'not_empty', 'b') 61 | # Function returns its arguments, as it did before decorating. 62 | assert second_result == (bot_object, update_object, 'not_empty', 'b') 63 | # reply_text was called only once, on the previous call. But not this time. Thus, call_count is still 1 64 | assert update_object.effective_message.reply_text.call_count == 1 65 | 66 | # TODO: Agregar test que simula como se pasa chat_data. Test paso pero implementacion fallo. 67 | -------------------------------------------------------------------------------- /commands/partido/command.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from telegram.ext import run_async, CommandHandler 3 | 4 | from utils.decorators import send_typing_action, log_time 5 | from utils.utils import soupify_url 6 | 7 | 8 | @log_time 9 | @send_typing_action 10 | @run_async 11 | def partido(bot, update): 12 | try: 13 | soup = soupify_url('https://mundoazulgrana.com.ar/sanlorenzo/') 14 | except requests.exceptions.ReadTimeout: 15 | update.message.reply_text('En estos momentos no puedo darte esta info.') 16 | return 17 | 18 | try: 19 | partido = soup.find_all('div', {'class': 'widget-partido'})[1].find( 20 | 'div', {'class': 'cont'} 21 | ) 22 | logo, *info = info_de_partido(partido) 23 | except ValueError: 24 | update.message.reply_text('No pude leer el próximo partido.\n' 25 | 'Podes chequearlo [acá](https://mundoazulgrana.com.ar/sanlorenzo/)', 26 | parse_mode='markdown') 27 | return 28 | bot.send_photo(chat_id=update.message.chat_id, photo=logo) 29 | bot.send_message(chat_id=update.message.chat_id, text='\n'.join(info)) 30 | 31 | 32 | # Helper func for partido 33 | def info_de_partido(partido): 34 | """ 35 |
36 | Hurac�n 37 |
20 38 | ENE 39 |
40 |

18:00/FOX Sports Premium

41 |

Pedro Bidegain/ 42 | Herrera 43 |

44 |

45 | 46 | 47 | 48 |
49 | 50 | 51 | Args: 52 | partido: raw html of the site 53 | 54 | Returns: 55 | tuple: partido attributes 56 | """ 57 | try: 58 | logo = partido.img.attrs['src'] 59 | fecha = partido.find('div', {'class': 'temp'}).text 60 | hora_tv = partido.find('p').text 61 | estadio_arbitro = partido.p.find_next_sibling('p').text 62 | except Exception: 63 | raise ValueError('Website html has changed. Review parsing') 64 | 65 | return logo, fecha, hora_tv, estadio_arbitro 66 | 67 | 68 | partido_handler = CommandHandler('partido', partido) 69 | -------------------------------------------------------------------------------- /callbacks/handler.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import logging 3 | 4 | from telegram.ext import CallbackQueryHandler 5 | 6 | from commands.dolar.callback import dolarhoy_callback 7 | from commands.dolar.keyboards import banco_keyboard 8 | from commands.hoypido.callback import hoypido_callback 9 | from commands.hoypido.keyboard import hoypido_keyboard 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | command_callback = {'dolarhoy': dolarhoy_callback, 'hoypido': hoypido_callback} 14 | 15 | 16 | def handle_callbacks(bot, update, chat_data): 17 | # Get the handler based on the commands 18 | context = chat_data.get('context') 19 | if not context: 20 | user = update.effective_user.first_name 21 | message = ( 22 | f"Perdón {user}, no pude traer la info que me pediste.\n" 23 | f"Probá invocando de nuevo el comando a ver si me sale 😊" 24 | ) 25 | logger.info(f"Conflicting update: '{update.to_dict()}'. Chat data: {chat_data}") 26 | bot.send_message( 27 | chat_id=update.callback_query.message.chat_id, 28 | text=message, 29 | parse_mode='markdown', 30 | ) 31 | # Notify telegram we have answered 32 | update.callback_query.answer(text='') 33 | return 34 | 35 | # Get user selection 36 | answer = update.callback_query.data 37 | 38 | callback_handler = command_callback[context['command']] 39 | 40 | # Get the relevant info based on user choice 41 | handled_response = callback_handler(context['data'], answer) 42 | # Notify that api we have succesfully handled the query 43 | update.callback_query.answer(text='') 44 | 45 | # Rebuild the same keyboard 46 | if context['command'] == 'dolarhoy': 47 | keyboard = banco_keyboard(context['data']) 48 | elif context['command'] == 'hoypido': 49 | comidas = context['data'] 50 | keyboard = hoypido_keyboard(comidas) 51 | 52 | if context.get('edit_original_text'): 53 | update.callback_query.edit_message_text( 54 | text=handled_response, reply_markup=keyboard, parse_mode='markdown' 55 | ) 56 | else: 57 | bot.send_message( 58 | chat_id=update.callback_query.message.chat_id, 59 | text=handled_response, 60 | parse_mode='markdown', 61 | ) 62 | 63 | 64 | callback_handler = CallbackQueryHandler(handle_callbacks, pass_chat_data=True) 65 | -------------------------------------------------------------------------------- /tests/commands/aproximacion/test_methods.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import pytest 3 | 4 | from commands.aproximacion.gauss_seidel import solve_by_gauss_seidel 5 | from commands.aproximacion.jacobi import solve_by_jacobi 6 | 7 | 8 | @pytest.mark.parametrize( 9 | "A, B, expected_solution", 10 | [ 11 | ( 12 | np.array([[9.0, 3.0], [2.0, 7.0]]), 13 | np.array([1.0, 2.0]), 14 | [0.0175438596, 0.2807017544], 15 | ), 16 | ( 17 | np.array([[4.0, 1.0], [1.5, 2.0]]), 18 | np.array([0.0, 3.0]), 19 | [-0.4615384615, 1.8461538462], 20 | ), 21 | (np.array([[2.0, 0.0], [3.5, 5.0]]), np.array([1.0, 10.0]), [0.5, 1.65]), 22 | ( 23 | np.array([[5.0, 2.0], [3.0, 4.0]]), 24 | np.array([2.0, 3.0]), 25 | [0.1428571428, 0.6428571429], 26 | ), 27 | ( 28 | np.array([[3.0, -2.0], [0.0, 2.0]]), 29 | np.array([2.0, 3.0]), 30 | [1.6666666667, 1.5], 31 | ), 32 | ( 33 | np.array([[10.0, -5.0], [4.0, 5.0]]), 34 | np.array([2.0, 3.0]), 35 | [0.3571428477, 0.3142857218], 36 | ), 37 | ( 38 | np.array([[-10.0, 2.0], [3.0, -11.0]]), 39 | np.array([5.0, -2.0]), 40 | [-0.4903846154, 0.0480769231], 41 | ), 42 | ( 43 | np.array([[3.0, 1.0], [1.0, 10.0]]), 44 | np.array([-20.0, 50.0]), 45 | [-8.6206896552, 5.8620689655], 46 | ), 47 | ( 48 | np.array([[5.0, -1.0, 3.0], [1.0, 6.0, -4], [-1.0, 2, -4.0]]), 49 | np.array([3.0, 7.0, 1.0]), 50 | [0.75, 1.125, 0.125], 51 | ), 52 | ( 53 | np.array([[-10.0, 1.0, 2.0], [-3.0, 30.0, 1], [2.0, 5, 10.0]]), 54 | np.array([-8.0, 1.0, 9.0]), 55 | [0.94230769, 0.10560625, 0.65873533], 56 | ), 57 | ( 58 | np.array([[6.0, 2.0, 1.0], [4.0, 8.0, 4.0], [1.0, -1, 5.0]]), 59 | np.array([-1.0, 1.0, 2.0]), 60 | [-0.25, 0.0227272727, 0.4545454545], 61 | ), 62 | ], 63 | ) 64 | def test_methods_give_correct_solution(A, B, expected_solution): 65 | np.testing.assert_allclose( 66 | solve_by_gauss_seidel(A, B, iterations=50), expected_solution 67 | ) 68 | np.testing.assert_allclose(solve_by_jacobi(A, B, iterations=50), expected_solution) 69 | -------------------------------------------------------------------------------- /commands/retro/commands.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import datetime as d, timezone, timedelta 3 | 4 | from telegram.ext import run_async 5 | 6 | from commands.retro.models import RetroItem, Session 7 | from utils.decorators import send_typing_action, log_time, group_only, admin_only 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | GMT_BUENOS_AIRES = -3 13 | 14 | @log_time 15 | @send_typing_action 16 | @run_async 17 | @group_only 18 | def retro_add(bot, update, args): 19 | if not args: 20 | update.message.reply_text( 21 | 'Tenes que agregar algo al retro bucket. `/retro mas recursos`', 22 | parse_mode='markdown', 23 | ) 24 | return 25 | retro_item = ' '.join(args) 26 | user = update.effective_user.first_name 27 | buenos_aires_offset = timezone(timedelta(hours=GMT_BUENOS_AIRES)) 28 | date = d.now(buenos_aires_offset) 29 | save_retro_item(retro_item, user, date) 30 | update.message.reply_text( 31 | '✅ Listo. Tu mensaje fue guardado para la retro.\n' 32 | 'Para recordarlo en la retro escribí `/retroitems`', 33 | parse_mode='markdown', 34 | ) 35 | logger.info("Retro event added: %s %s %s", user, retro_item, date) 36 | 37 | 38 | @log_time 39 | def save_retro_item(retro_item, user, date_time): 40 | session = Session() 41 | item = RetroItem(user=user, text=retro_item, datetime=date_time) 42 | session.add(item) 43 | session.commit() 44 | 45 | 46 | @log_time 47 | @send_typing_action 48 | @run_async 49 | @group_only 50 | def show_retro_items(bot, update): 51 | items = get_retro_items() 52 | if items: 53 | update.message.reply_text( 54 | '\n\n'.join( 55 | f"*{item.user}* | {item.text.capitalize()} | {_localize_time(item.datetime)}" 56 | for item in items 57 | ), 58 | parse_mode='markdown' 59 | ) 60 | else: 61 | update.message.reply_text('📋 No hay ningún retroitem guardado todavía') 62 | 63 | 64 | @log_time 65 | def get_retro_items(): 66 | session = Session() 67 | return session.query(RetroItem).filter_by(expired=False).all() 68 | 69 | 70 | def _localize_time(date): 71 | # Turns UTC time into buenos aires time. 72 | date = date + timedelta(hours=GMT_BUENOS_AIRES) 73 | return date.strftime('%A %d/%m %H:%M').capitalize() 74 | 75 | 76 | @log_time 77 | @admin_only 78 | def expire_retro(bot, update): 79 | session = Session() 80 | for item in session.query(RetroItem): 81 | item.expired = True 82 | session.commit() 83 | update.message.reply_text('✅ Listo. El registro de retroitems fue reseteado.') 84 | -------------------------------------------------------------------------------- /inlinequeries/snippets.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import time 4 | from functools import partial 5 | 6 | from Levenshtein import jaro_winkler 7 | 8 | from telegram import ( 9 | InlineQueryResultArticle, 10 | ParseMode, 11 | InputTextMessageContent 12 | ) 13 | from telegram.ext import InlineQueryHandler 14 | import logging 15 | 16 | from telegram.utils.helpers import escape_markdown 17 | 18 | from commands.snippets.utils import select_all_snippets 19 | from utils.constants import MINUTE 20 | from utils.decorators import requires_auth, inline_auth 21 | 22 | logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 23 | level=logging.INFO) 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | def _article(id, title, text, parse_mode=ParseMode.MARKDOWN): 29 | reply_text = f"*» {title}*\n{escape_markdown(text)}" 30 | try: 31 | msg = InputTextMessageContent(reply_text, parse_mode=parse_mode) 32 | except Exception: 33 | logger.error(text) 34 | return InlineQueryResultArticle( 35 | id=id, 36 | title=f'{title}', 37 | description='Code snippets', 38 | input_message_content=msg, 39 | ) 40 | 41 | 42 | def is_similar(a, b, tolerance=0.58): 43 | similarity = jaro_winkler(a.lower(), b.lower()) 44 | return similarity > tolerance 45 | 46 | 47 | def _filter_snippets(snippets, filter_f): 48 | return [ 49 | _article(id, snippet_key, content) 50 | for id, snippet_key, content in snippets if filter_f(snippet_key) 51 | ] 52 | 53 | 54 | @inline_auth 55 | def inlinequery(bot, update, chat_data): 56 | """Show all snippets if query is empty string or filter by string similarity""" 57 | user_input = update.inline_query.query 58 | 59 | # Cache for 5 minutes in chat_data. 60 | snippets = chat_data.get('snippets') 61 | if not snippets or (chat_data.get('last_update', 0) - time.time()) > 5 * MINUTE: 62 | chat_data['snippets'] = snippets = select_all_snippets() 63 | logger.info('Recaching snippets') 64 | 65 | logger.info(f'Snippets: {len(snippets)}') 66 | 67 | if not snippets: 68 | return 69 | if len(user_input) == 0: 70 | results = _filter_snippets(snippets, lambda s: True) 71 | else: 72 | results = _filter_snippets(snippets, partial(is_similar, user_input.lower(), tolerance=0.58)) 73 | logger.info(f"Filtered results: {len(results)} by '{user_input}'") 74 | 75 | update.inline_query.answer(results, cache_time=0) 76 | chat_data['last_update'] = time.time() 77 | 78 | 79 | inline_snippets = InlineQueryHandler(inlinequery, pass_chat_data=True) 80 | -------------------------------------------------------------------------------- /commands/subte/suscribers/command.py: -------------------------------------------------------------------------------- 1 | from telegram.ext import run_async, CommandHandler 2 | 3 | from commands.subte.suscribers.constants import MISSING_LINEA_MESSAGE, LINEAS, UNSUSCRIBED_MESSAGE 4 | from commands.subte.suscribers.db import get_suscriptors, remove_subte_suscriber 5 | from commands.subte.suscribers.utils import add_suscriber_to_linea 6 | from utils.decorators import handle_empty_arg, private_chat_only, admin_only, send_typing_action 7 | 8 | 9 | @send_typing_action 10 | @run_async 11 | @private_chat_only 12 | @handle_empty_arg(required_params=('args',), error_message=MISSING_LINEA_MESSAGE, parse_mode='markdown') 13 | def suscribe(bot, update, args): 14 | """Suscribe to a subte line to receive updates via private message.""" 15 | linea = args[0] 16 | if linea.upper() not in LINEAS: 17 | update.message.reply_text( 18 | '🚫 La linea elegida no existe. Intentá de nuevo con `/suscribe `', 19 | parse_mode='markdown' 20 | ) 21 | return 22 | 23 | linea = linea.upper() 24 | user = update.message.from_user 25 | added = add_suscriber_to_linea(user.id, user.name, linea) 26 | if added: 27 | msg = f'✅ Listo. Serás notificado via chat privado de los updates de la linea {linea}' 28 | else: 29 | msg = f'🚫 Algo salió mal. Mejor intentá más tarde' 30 | 31 | update.message.reply_text(msg) 32 | 33 | 34 | @send_typing_action 35 | @run_async 36 | @handle_empty_arg(required_params=('args',), error_message=UNSUSCRIBED_MESSAGE, parse_mode='markdown') 37 | def unsuscribe(bot, update, args): 38 | linea = args[0] 39 | if linea.upper() not in LINEAS: 40 | update.message.reply_text('🚫 La linea elegida no existe.') 41 | return 42 | 43 | user = update.message.from_user 44 | removed = remove_subte_suscriber(str(user.id), linea.upper()) 45 | if removed: 46 | msg = f'✅ Listo. Tu suscripción quedó cancelada' 47 | else: 48 | msg = f'👻 Algo salió mal. Mejor intentá más tarde' 49 | 50 | update.message.reply_text(msg, quote=False) 51 | 52 | 53 | @send_typing_action 54 | @run_async 55 | @admin_only 56 | def suscribers(bot, update): 57 | items = get_suscriptors() 58 | if items: 59 | update.message.reply_text( 60 | '\n'.join( 61 | f"{item.user_name} | {item.linea.upper()}" 62 | for item in items 63 | ) 64 | ) 65 | else: 66 | update.message.reply_text('📋 Aún no hay suscriptores a los subte updates') 67 | 68 | 69 | subte_suscriptions = CommandHandler('suscribe', suscribe, pass_args=True) 70 | subte_desuscriptions = CommandHandler('unsuscribe', unsuscribe, pass_args=True) 71 | subte_show_suscribers = CommandHandler('suscribers', suscribers) 72 | -------------------------------------------------------------------------------- /commands/yts/utils.py: -------------------------------------------------------------------------------- 1 | from collections import namedtuple 2 | import logging 3 | 4 | from telegram import InputMediaPhoto 5 | from telegram.error import TimedOut 6 | 7 | from commands.serie.utils import rating_stars 8 | from utils.utils import normalize 9 | 10 | Torrent = namedtuple('Torrent', ['url', 'size', 'seeds', 'quality']) 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def get_minimal_movie(movie, trim_description=True): 16 | """Return image, title, synopsis, and Torrents from a movie.""" 17 | title = movie['title_long'] 18 | imdb = movie['imdb_code'] 19 | yt_trailer = movie['yt_trailer_code'] 20 | if trim_description: 21 | synopsis = normalize(movie['synopsis'], limit=150, trim_end='..') 22 | else: 23 | synopsis = movie['synopsis'] 24 | 25 | rating = movie['rating'] 26 | image = movie['large_cover_image'] 27 | return title, synopsis, rating, imdb, yt_trailer, image 28 | 29 | 30 | def get_torrents(movie): 31 | return [get_torrent(torrent) for torrent in movie['torrents']] 32 | 33 | 34 | def prettify_yts_movie(title, synopsis, rating): 35 | # Add torrents as optional download buttons? 36 | message = f"{title}\n{rating_stars(rating)}\n{synopsis}\n" 37 | return message 38 | 39 | 40 | def get_torrent(torrent): 41 | """Tranforms 42 | { 43 | "url": "https://yts.am/torrent/download/035AF68CEF3D90223BD6B0AF7749D758E3758C32", 44 | "hash": "035AF68CEF3D90223BD6B0AF7749D758E3758C32", 45 | "quality": "720p", 46 | "seeds": 288, 47 | "peers": 207, 48 | "size": "845.33 MB", 49 | "size_bytes": 886392750, 50 | "date_uploaded": "2018-10-21 08:43:31", 51 | "date_uploaded_unix": 1540104211 52 | } 53 | into Torrent namedtuple with only url, size, seeds and quality 54 | """ 55 | return Torrent( 56 | url=torrent['url'], 57 | size=torrent['size'], 58 | seeds=torrent['seeds'], 59 | quality=torrent['quality'], 60 | ) 61 | 62 | 63 | def prettify_torrent(movie_name, torrent): 64 | """Pretty print a Torrent namedtuple""" 65 | return ( 66 | f"[{movie_name}]({torrent.url})\n" 67 | f"🌱 Seeds: {torrent.seeds}\n" 68 | f"🗳 Size: {torrent.size}\n" 69 | f"🖥 Quality: {torrent.quality}\n" 70 | ) 71 | 72 | 73 | def get_photo(image_url): 74 | """Build InputMediaPhoto from image url""" 75 | try: 76 | return InputMediaPhoto(image_url) 77 | except TimedOut: 78 | logger.info('Request for photo from %s timed out.', image_url) 79 | logger.info('Retrying..') 80 | try: 81 | return InputMediaPhoto(image_url) 82 | except TimedOut: 83 | logger.info('Retry Failed.') 84 | return None 85 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with Python 3.10 3 | # by the following command: 4 | # 5 | # pip-compile --output-file=requirements.txt --resolver=backtracking requirements.in 6 | # 7 | appdirs==1.4.4 8 | # via subliminal 9 | babelfish==0.6.0 10 | # via 11 | # guessit 12 | # subliminal 13 | beautifulsoup4==4.11.1 14 | # via 15 | # -r requirements.in 16 | # subliminal 17 | certifi==2022.12.7 18 | # via 19 | # python-telegram-bot 20 | # requests 21 | cffi==1.15.1 22 | # via cryptography 23 | chardet==5.1.0 24 | # via 25 | # pysrt 26 | # subliminal 27 | charset-normalizer==2.1.1 28 | # via requests 29 | click==8.1.3 30 | # via subliminal 31 | cryptography==38.0.4 32 | # via python-telegram-bot 33 | dateparser==1.1.5 34 | # via -r requirements.in 35 | decorator==5.1.1 36 | # via dogpile-cache 37 | dogpile-cache==1.1.8 38 | # via subliminal 39 | enzyme==0.4.1 40 | # via subliminal 41 | future==0.18.2 42 | # via python-telegram-bot 43 | greenlet==2.0.1 44 | # via sqlalchemy 45 | guessit==3.5.0 46 | # via subliminal 47 | idna==3.4 48 | # via requests 49 | levenshtein==0.20.9 50 | # via python-levenshtein 51 | lxml==4.9.2 52 | # via -r requirements.in 53 | numpy==1.24.1 54 | # via -r requirements.in 55 | pbr==5.11.0 56 | # via stevedore 57 | psycopg2-binary==2.9.5 58 | # via -r requirements.in 59 | pycparser==2.21 60 | # via cffi 61 | pysrt==1.1.2 62 | # via subliminal 63 | python-dateutil==2.8.2 64 | # via 65 | # dateparser 66 | # guessit 67 | python-levenshtein==0.20.9 68 | # via -r requirements.in 69 | python-telegram-bot==11.0.0 70 | # via -r requirements.in 71 | pytz==2022.7 72 | # via 73 | # -r requirements.in 74 | # dateparser 75 | # subliminal 76 | pytz-deprecation-shim==0.1.0.post0 77 | # via tzlocal 78 | rapidfuzz==2.13.7 79 | # via levenshtein 80 | rarfile==4.0 81 | # via subliminal 82 | rebulk==3.1.0 83 | # via guessit 84 | regex==2022.10.31 85 | # via dateparser 86 | requests==2.28.1 87 | # via 88 | # -r requirements.in 89 | # subliminal 90 | six==1.16.0 91 | # via 92 | # python-dateutil 93 | # subliminal 94 | soupsieve==2.3.2.post1 95 | # via beautifulsoup4 96 | sqlalchemy==1.4.45 97 | # via -r requirements.in 98 | stevedore==4.1.1 99 | # via 100 | # dogpile-cache 101 | # subliminal 102 | subliminal==2.1.0 103 | # via -r requirements.in 104 | tzdata==2022.7 105 | # via pytz-deprecation-shim 106 | tzlocal==4.2 107 | # via dateparser 108 | urllib3==1.26.13 109 | # via requests 110 | youtube-dl==2021.12.17 111 | # via -r requirements.in 112 | -------------------------------------------------------------------------------- /commands/snippets/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import psycopg2 4 | from psycopg2.errorcodes import UNIQUE_VIOLATION 5 | 6 | from commands.snippets.constants import DEFAULT_ERROR_MESSAGE, DUPLICATE_KEY_MESSAGE 7 | from utils.decorators import log_time 8 | 9 | logger = logging.getLogger(__name__) 10 | DB = os.environ['DATABASE_URL'] 11 | 12 | 13 | @log_time 14 | def save_to_db(key, content): 15 | """Returns succcess and error code if operation was not succesfull""" 16 | try: 17 | with psycopg2.connect(DB) as conn: 18 | with conn.cursor() as curs: 19 | curs.execute( 20 | "INSERT INTO bot_memory (key, content) VALUES (%s, %s);", 21 | (key, content), 22 | ) 23 | logger.info(f"Content successfully added to db with key '{key}'") 24 | conn.commit() 25 | success = (True, None) 26 | 27 | except psycopg2.IntegrityError as e: 28 | if e.pgcode == UNIQUE_VIOLATION: 29 | logger.info("Snippet key already exists on db") 30 | success = (False, DUPLICATE_KEY_MESSAGE) 31 | else: 32 | logger.exception("Snippet could not be saved!") 33 | success = (False, DEFAULT_ERROR_MESSAGE) 34 | 35 | except Exception: 36 | logger.exception("Error writing to db") 37 | success = (False, repr(Exception)) 38 | 39 | return success 40 | 41 | 42 | @log_time 43 | def lookup_content(key): 44 | try: 45 | with psycopg2.connect(DB) as conn: 46 | with conn.cursor() as curs: 47 | curs.execute( 48 | 'SELECT key, content from bot_memory WHERE key=%s;', (key,) 49 | ) 50 | content = curs.fetchone() 51 | logger.info("Content retrieved under key %s", key) 52 | conn.commit() 53 | return content 54 | 55 | except Exception: 56 | logger.exception("Error writing to db") 57 | return None 58 | 59 | 60 | @log_time 61 | def select_all_snippets(): 62 | try: 63 | with psycopg2.connect(DB) as conn: 64 | with conn.cursor() as curs: 65 | curs.execute('SELECT * FROM bot_memory') 66 | content = curs.fetchall() 67 | conn.commit() 68 | return content 69 | 70 | except Exception: 71 | logger.exception("Error writing to db") 72 | return None 73 | 74 | 75 | @log_time 76 | def remove_snippet(key): 77 | try: 78 | with psycopg2.connect(DB) as conn: 79 | with conn.cursor() as curs: 80 | curs.execute('DELETE FROM bot_memory WHERE key=%s', (key,)) 81 | conn.commit() 82 | logger.info("Status: %s", curs.statusmessage) 83 | return curs.statusmessage == 'DELETE 1' 84 | except Exception: 85 | logger.exception("Error writing to db") 86 | return False 87 | 88 | 89 | def link_key(cmd): 90 | return f'[{cmd}](http://t.me/share/url?url=/get {cmd})' 91 | -------------------------------------------------------------------------------- /commands/aproximacion/jacobi.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Implements the jacobi iterative method to solve the A.x=B equation. 4 | 5 | Heavily inspired on: 6 | https://www.quantstart.com/articles/Jacobi-Method-in-Python-and-NumPy 7 | """ 8 | 9 | import numpy as np 10 | 11 | 12 | def solve_by_jacobi(A, B, error_bound=0.001, x=None): 13 | """Solves the equation Ax=b via the Jacobi iterative method. 14 | 15 | See https://en.wikipedia.org/wiki/Jacobi_method for implementation details. 16 | 17 | The idea is decomposing A so that: 18 | A = D + R 19 | where D is a diagonal matrix and R the remainder (A - diag(A)) 20 | With that notation we have: 21 | A.x = B 22 | (D+R).x = B 23 | D.x + R.x = B 24 | D.x = B - R.x 25 | D⁻¹.D.x = D⁻¹.(B - R.x) 26 | x = D⁻¹.(B - R.x) 27 | 28 | And with a bit of analysis we know that the product 29 | D⁻¹.(B - R.x) 30 | is the same as taking each element on (B - R.x) matrix and divide it 31 | by the inverse of the coefficient of the Aᵢᵢ element of the diagonal matrix. 32 | 33 | Args: 34 | A (np.array): Matrix A. It contains the coefficient of the incognitas 35 | B (np.array: Matrix B. It contains the result of each equation 36 | error_bound (float): Minimum acceptable error to the real answer. Once error is less than error_bound, stop calculating. 37 | x (list[int]): Initial vector guess (Optional) 38 | """ 39 | np.seterr(all='raise') 40 | 41 | # Creates an initial guess if needed 42 | if x is None: 43 | x = np.zeros(len(A[0])) 44 | elif isinstance(x, list): 45 | x = np.array(x) 46 | 47 | # Create a vector of the diagonal elements of A 48 | D = np.diag(A) 49 | 50 | # Get the remainder matrix by substracting the diagonal 51 | R = A - np.diagflat(D) 52 | 53 | results = [(x, '-', '-', '-')] 54 | value_diff = error_bound + 1 55 | while not (value_diff <= error_bound): 56 | # x = (B - R.x) / D 57 | old_x = x 58 | x = (B - np.dot(R, x)) / D 59 | 60 | # Save step results 61 | norma_1 = norm_1(x - old_x) 62 | norma_2 = norm_2(x - old_x) 63 | norma_inf = infinite_norm(x - old_x) 64 | 65 | # Evaluate if we should continue. If value 66 | value_diff = norma_2 67 | results.append((x, norma_1, norma_2, norma_inf)) 68 | 69 | return x, results 70 | 71 | 72 | def infinite_norm(array): 73 | return np.linalg.norm(array, ord=np.inf) 74 | 75 | 76 | def norm_1(array): 77 | return np.linalg.norm(array, ord=1) 78 | 79 | 80 | def norm_2(array): 81 | return np.linalg.norm(array, ord=None) 82 | 83 | 84 | """ 85 | A = np.array([ 86 | [5.0, -1.0, 3.0], 87 | [1.0, 6.0, -4], 88 | [-1.0, 2, -4.0], 89 | ]) 90 | b = np.array([3.0, 7.0, 1.0]) 91 | A = np.array([ 92 | [3.0, 1.0], 93 | [0.0, 4.0] 94 | ]) 95 | 96 | b = np.array([1.0, 2.0]) 97 | 98 | sol, res = solve_by_jacobi(A, b, error_bound=0.0001) 99 | 100 | np.set_printoptions(precision=10) 101 | 102 | print("Solución (r): %s" % sol) 103 | """ 104 | -------------------------------------------------------------------------------- /commands/register/command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from sqlalchemy.exc import IntegrityError 4 | from telegram.error import BadRequest 5 | from telegram.ext import CommandHandler 6 | 7 | from commands.register.db import add_user, _get_users 8 | from utils.decorators import handle_empty_arg, send_typing_action, admin_only 9 | from utils.utils import send_message_to_admin 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | def register(bot, update): 16 | """A user that wants to access private commands must first register""" 17 | user = update.message.from_user 18 | user_data_string = _user_to_string(user) 19 | send_message_to_admin( 20 | bot, 21 | f'{user.name} wants to start using AmbroBot\n\n' 22 | f'User details:\n{user_data_string}', 23 | ) 24 | update.message.reply_text('✅ Your registration request has been sent.\nPlease wait for approval.. ⏳', quote=False) 25 | 26 | 27 | @handle_empty_arg(required_params=('args',)) 28 | @send_typing_action 29 | @admin_only 30 | def authorize(bot, update, args): 31 | user = _string_to_user(args[0]) 32 | added = add_user_to_db(user) 33 | if added: 34 | update.message.reply_text('✅ User added to db') 35 | # send message to new registered user 36 | try: 37 | bot.send_message(chat_id=user['id'], text='✅ Has sido autorizad@ para hablar con Cuervot!') 38 | except BadRequest: 39 | logger.error("Unable to send message. User hasn't talked yet privately with Cuervot.") 40 | else: 41 | update.message.reply_text('🚫 Error saving user to db') 42 | 43 | 44 | @send_typing_action 45 | @admin_only 46 | def show_users(bot, update): 47 | users = _get_users() 48 | total_users = len(users) 49 | result = '\n'.join([str(user) for user in users]) 50 | message = f'Total users: {total_users}\n{result}' 51 | update.message.reply_text(message) 52 | 53 | 54 | def _user_to_string(user): 55 | """Receives a user object and returns a string representation""" 56 | return ( 57 | f"id:{user.id};" 58 | f"first_name:{user.first_name};" 59 | f"last_name:{user.last_name};" 60 | f"username:{user.username}" 61 | ) 62 | 63 | 64 | def _string_to_user(user_string): 65 | """Receives a user string and returns a dict with its attributes.""" 66 | try: 67 | fields = user_string.split(';') 68 | user_attrs = [f.split(':') for f in fields] 69 | user_dict = { 70 | attrib:value for attrib, value in user_attrs 71 | } 72 | return user_dict 73 | except ValueError: 74 | logger.info("Malformed user string") 75 | return None 76 | 77 | 78 | def add_user_to_db(user): 79 | try: 80 | add_user(user) 81 | return True 82 | 83 | except IntegrityError: 84 | logger.error("User already exists") 85 | return False 86 | except Exception: 87 | logger.exception("Error saving user to db") 88 | return False 89 | 90 | 91 | register_user = CommandHandler('register', register) 92 | authorize_handler = CommandHandler('authorize', authorize, pass_args=True) 93 | show_users_handler = CommandHandler('users', show_users) 94 | -------------------------------------------------------------------------------- /commands/subte/updates/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import random 3 | import re 4 | 5 | from commands.subte.constants import DELAY_ICONS, SUBWAY_ICON, SUBWAY_LINE_OK 6 | from commands.subte.suscribers.db import get_suscriptors_by_line 7 | 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | LINEA = re.compile(r'Linea([A-Z]{1})') 12 | 13 | 14 | def get_update_info(alert): 15 | linea = _get_linea_name(alert) 16 | incident = _get_incident_text(alert) 17 | return linea, incident 18 | 19 | 20 | def _get_linea_name(alert): 21 | try: 22 | nombre_linea = alert['informed_entity'][0]['route_id'] 23 | except (IndexError, KeyError): 24 | return None 25 | 26 | try: 27 | nombre_linea = LINEA.match(nombre_linea).group(1) 28 | except AttributeError: 29 | # There was no linea match -> Premetro y linea Urquiza 30 | nombre_linea = nombre_linea.replace('PM-', 'PM ') 31 | 32 | return nombre_linea 33 | 34 | 35 | def _get_incident_text(alert): 36 | translations = alert['header_text']['translation'] 37 | spanish_desc = next((translation 38 | for translation in translations 39 | if translation['language'] == 'es'), None) 40 | if spanish_desc is None: 41 | logger.info('raro, no tiene desc en español. %s' % alert) 42 | return None 43 | 44 | return spanish_desc['text'] 45 | 46 | 47 | def prettify_updates(updates): 48 | delay_icon = random.choice(DELAY_ICONS) 49 | return '\n'.join( 50 | pretty_update(linea, status, delay_icon) 51 | for linea, status in updates.items() 52 | ) 53 | 54 | 55 | def pretty_update(linea, update, icon=SUBWAY_ICON): 56 | return f'{linea} | {icon}️ {update}' 57 | 58 | 59 | def send_new_incident_updates(bot, context, status_updates): 60 | msg_count = 0 61 | new_incidents = {line: update for line, update in status_updates.items() if update != context.get(line)} 62 | logger.info('New incidents:\n%s', new_incidents) 63 | for linea, update in new_incidents.items(): 64 | for suscription in get_suscriptors_by_line(linea): 65 | # Status Update may have changed but because another line is suspended. 66 | # If we are here, it means the status of the suscribed line has changed. 67 | logger.info(f'Sending update message on line {linea} to {suscription.user_name}') 68 | bot.send_message(chat_id=suscription.user_id, text=pretty_update(linea, update)) 69 | msg_count += 1 70 | 71 | return msg_count 72 | 73 | 74 | def send_service_normalization_updates(bot, context, status_updates): 75 | # Send update on lines that had issues but were solved 76 | # Solved issues were part of the context, but are not part of the status_updates, because they were solved. 77 | msg_count = 0 78 | solved_issues = {line: st for line, st in context.items() if line not in status_updates} 79 | logger.info('Solved issues:\n%s', solved_issues) 80 | for line, previous_status in solved_issues.items(): 81 | for suscription in get_suscriptors_by_line(line): 82 | logger.info(f'Send OK status about {line} to {suscription.user_name}') 83 | bot.send_message(chat_id=suscription.user_id, text=SUBWAY_LINE_OK.format(line)) 84 | msg_count += 1 85 | 86 | return msg_count 87 | -------------------------------------------------------------------------------- /commands/aproximacion/utils.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import logging 3 | 4 | import numpy as np 5 | 6 | from commands.aproximacion.constants import JACOBI, GAUSS_SEIDEL 7 | from commands.aproximacion.gauss_seidel import solve_by_gauss_seidel 8 | from commands.aproximacion.jacobi import solve_by_jacobi 9 | 10 | 11 | methods = { 12 | JACOBI: solve_by_jacobi, 13 | GAUSS_SEIDEL: solve_by_gauss_seidel 14 | } 15 | 16 | 17 | logger = logging.getLogger(__name__) 18 | 19 | 20 | def _parse_matrix(text): 21 | rows = text.split('\n') 22 | try: 23 | matrix = [list(map(int, r.split(' '))) for r in rows] 24 | except (TypeError, ValueError): 25 | logger.info("El valor %s no contiene algo casteable a int.", text) 26 | return None 27 | if len(matrix[0]) != len(matrix): 28 | # If it has more columns than rows 29 | return None 30 | return matrix 31 | 32 | 33 | def number_callback(bot, update, *args, **kwargs): 34 | """Ignore presses on matrix numbers""" 35 | update.callback_query.answer(text='') 36 | 37 | 38 | def _is_diagonal_dominant(matrix): 39 | """Calculates if a matrix is diagonally dominant (by row or column)""" 40 | COLUMN, ROW = 0, 1 41 | abs_matrix = np.abs(matrix) 42 | 43 | return np.all( 44 | 2 * np.diag(abs_matrix) >= np.sum(abs_matrix, axis=ROW) 45 | ) or np.all( 46 | 2 * np.diag(abs_matrix) >= np.sum(abs_matrix, axis=COLUMN) 47 | ) 48 | 49 | 50 | def _is_square(matrix): 51 | return len(matrix) == len(matrix[0]) 52 | 53 | 54 | def aproximate(method, a_matrix, b_matrix, cota_de_error, v_inicial, decimals): 55 | apromixation_method = methods[method] 56 | logger.info( 57 | 'Invocando a metodo %s con args: A: %s, B: %s, cota: %s, v_inicial: %s' 58 | % (apromixation_method, a_matrix, b_matrix, cota_de_error, v_inicial) 59 | ) 60 | res, details = apromixation_method(a_matrix, b_matrix, cota_de_error, v_inicial) 61 | return res, details 62 | 63 | 64 | opposite_method = { 65 | JACOBI: GAUSS_SEIDEL, 66 | GAUSS_SEIDEL: JACOBI 67 | } 68 | 69 | 70 | def prettify_details(result_steps, limit): 71 | # Prettify np.arrays into markdown pretty 72 | return '\n'.join( 73 | f"#{i:<2} | {_minify_array(results[0], limit)}" 74 | for i, results in enumerate(result_steps) 75 | ) 76 | 77 | 78 | def _minify_array(array, limit): 79 | return tuple([f'{elem:.{limit}f}' for elem in list(array)]) 80 | 81 | 82 | def dump_results_to_csv(final_result, result_steps, decimal_precision, error_minimo): 83 | FILE_PATH = 'aproximation_result.csv' 84 | solution_len = len(final_result) 85 | 86 | with open(FILE_PATH, mode='w') as f: 87 | csv_writer = csv.writer( 88 | f, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL 89 | ) 90 | 91 | coordinates = [f'X_{i}' for i in range(solution_len)] 92 | csv_writer.writerow( 93 | ['i'] + coordinates + ['Norma 1', 'Norma 2', 'Norma 3', 'Criterio de Paro'] 94 | ) 95 | 96 | for i, step in enumerate(result_steps): 97 | array_elems = [round(number, decimal_precision) for number in step[0]] 98 | normas = [ 99 | round(norma, decimal_precision) if norma != '-' else '-' 100 | for norma in step[1:] 101 | ] 102 | row = [i] + array_elems + normas + [error_minimo] 103 | csv_writer.writerow(row) 104 | 105 | return FILE_PATH 106 | -------------------------------------------------------------------------------- /utils/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import signal 4 | 5 | from requests import ReadTimeout 6 | from telegram.error import TelegramError, Unauthorized, BadRequest, TimedOut 7 | 8 | import requests 9 | from bs4 import BeautifulSoup 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | # Generic utils 16 | def monospace(text): 17 | return f'```\n{text}\n```' 18 | 19 | 20 | def normalize(text, limit=11, trim_end='.'): 21 | """Trim and append . if text is too long. Else return it unmodified""" 22 | return f'{text[:limit]}{trim_end}' if len(text) > limit else text 23 | 24 | 25 | def soupify_url(url, timeout=2, encoding='utf-8', **kwargs): 26 | """Given a url returns a BeautifulSoup object""" 27 | try: 28 | r = requests.get(url, timeout=timeout, **kwargs) 29 | except ReadTimeout: 30 | logger.info("[soupify_url] Request for %s timed out.", url) 31 | raise 32 | except Exception as e: 33 | logger.error(f"Request for {url} could not be resolved", exc_info=True) 34 | raise ConnectionError(repr(e)) 35 | 36 | 37 | r.raise_for_status() 38 | r.encoding = encoding 39 | if r.status_code == 200: 40 | return BeautifulSoup(r.text, 'lxml') 41 | else: 42 | raise ConnectionError( 43 | f'{url} returned error status %s - ', r.status_code, r.reason 44 | ) 45 | 46 | 47 | def error_handler(bot, update, error): 48 | try: 49 | raise error 50 | except Unauthorized: 51 | logger.info("User unauthorized") 52 | except BadRequest as e: 53 | msg = getattr(error, 'message', None) 54 | if msg is None: 55 | raise 56 | if msg == 'Query_id_invalid': 57 | logger.info("We took too long to answer.") 58 | elif msg == 'Message is not modified': 59 | logger.info( 60 | "Tried to edit a message but text hasn't changed." 61 | " Probably a button in inline keyboard was pressed but it didn't change the message" 62 | ) 63 | return 64 | else: 65 | logger.info("Bad Request exception: %s", msg) 66 | 67 | except TimedOut: 68 | logger.info("Request timed out") 69 | bot.send_message( 70 | chat_id=update.effective_message.chat_id, text='The request timed out ⌛️' 71 | ) 72 | 73 | except TelegramError: 74 | logger.exception("A TelegramError occurred") 75 | 76 | finally: 77 | try: 78 | text = update.effective_message.text 79 | user = update.effective_user.name 80 | chat = update.effective_chat 81 | 82 | error_msg = (f"User: {user}\nText: {text}\n" 83 | f"Chat: {chat.id, chat.type, chat.username}\n" 84 | f"Error: {repr(error)} - {str(error)}") 85 | 86 | logger.info(f"Conflicting update: {error_msg}") 87 | 88 | except Exception: 89 | error_msg = f'Error found: {error}. Update: {update}' 90 | logger.error(error_msg, exc_info=True) 91 | 92 | send_message_to_admin(bot, error_msg) 93 | 94 | 95 | def send_message_to_admin(bot, message, **kwargs): 96 | bot.send_message(chat_id=os.environ['ADMIN_ID'], text=message, **kwargs) 97 | 98 | 99 | def signal_handler(signal_number, frame): 100 | sig_name = signal.Signals(signal_number).name 101 | logger.info(f'Captured signal number {signal_number}. Name: {sig_name}') 102 | -------------------------------------------------------------------------------- /commands/aproximacion/gauss_seidel.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | """ 3 | Implements the jacobi iterative method to solve the A.x=B equation. 4 | 5 | Heavily inspired on: 6 | https://www.quantstart.com/articles/Jacobi-Method-in-Python-and-NumPy 7 | """ 8 | 9 | import numpy as np 10 | from numpy.linalg import LinAlgError 11 | 12 | 13 | def solve_by_gauss_seidel(A, B, error_bound=0.001, x=None): 14 | """Solves the equation Ax=b via the Gauss Seidel iterative method. 15 | 16 | See https://en.wikipedia.org/wiki/Gauss%E2%80%93Seidel_method for implementation details. 17 | 18 | The idea is decomposing A so that: 19 | A = L + U 20 | where L is the lower triangular matrix of A (It includes the diagonal) 21 | and U is the strictly upper triangular matrix deriving of A. 22 | With that notation we have: 23 | A.x = B 24 | (L+U).x = B 25 | L.x + U.x = B 26 | L.x = B - U.x 27 | L⁻¹.L.x = L⁻¹.(B - U.x) 28 | x = L⁻¹.(B - U.x) 29 | 30 | And with a bit of analysis we know that the product 31 | L⁻¹.(B - U.x) 32 | is the same as taking each element on (B - U.x) matrix and divide it 33 | by the inverse of Lᵢⱼ element of the matrix. 34 | """ 35 | np.seterr(all='raise') 36 | 37 | # Creates an initial guess if needed 38 | if x is None: 39 | x = np.zeros(len(A[0])) 40 | elif isinstance(x, list): 41 | x = np.array(x) 42 | 43 | # Create a lower triangular matrix of A 44 | L = np.tril(A) 45 | # print("Matriz Triangular inferior:\n %s" % L) 46 | try: 47 | L_inverse = np.linalg.inv(L) 48 | except LinAlgError: 49 | raise ValueError( 50 | "La matriz triangular inferior de A no tiene inversa.\n" 51 | "Estás seguro que la matriz ingresada es diagonalmente dominante?" 52 | ) 53 | 54 | # Get the strictly upper triangular matrix of A 55 | U = A - L 56 | 57 | # Iterate n times / n=iterations 58 | results = [(x, '-', '-', '-')] 59 | value_diff = error_bound + 1 60 | while not (value_diff <= error_bound): 61 | old_x = x 62 | # x = L⁻¹.(B - U.x) 63 | x = np.matmul(L_inverse, B - np.dot(U, x)) 64 | 65 | # Save step results 66 | norma_1 = norm_1(x - old_x) 67 | norma_2 = norm_2(x - old_x) 68 | norma_inf = infinite_norm(x - old_x) 69 | 70 | # Evaluate if we should continue. If value 71 | value_diff = norma_2 72 | results.append((x, norma_1, norma_2, norma_inf)) 73 | 74 | return x, results 75 | 76 | 77 | def infinite_norm(array): 78 | return np.linalg.norm(array, ord=np.inf) 79 | 80 | 81 | def norm_1(array): 82 | return np.linalg.norm(array, ord=1) 83 | 84 | 85 | def norm_2(array): 86 | return np.linalg.norm(array, ord=None) 87 | 88 | 89 | """ 90 | 91 | A = np.array([ 92 | [5.0, -1.0, 3.0], 93 | [1.0, 6.0, -4], 94 | [-1.0, 2, -4.0], 95 | ]) 96 | b = np.array([3.0, 7.0, 1.0]) 97 | 98 | sol, res = solve_by_gauss_seidel(A, b, error_bound=0.0001, x=[1, 1, 1]) 99 | 100 | np.set_printoptions(precision=10) 101 | pprint(res) 102 | print("Solución (r): %s" % sol) 103 | 104 | 105 | 106 | A = np.array([ 107 | [6.0, 2.0, 1.0], 108 | [4.0, 8.0, 4.0], 109 | [1.0, -1, 5.0], 110 | ]) 111 | b = np.array([-1.0, 1.0, 2.0]) 112 | 113 | sol = solve_by_gauss_seidel(A, b, iterations=70) 114 | 115 | np.set_printoptions(precision=10) 116 | print("Solución (r): %s" % sol) 117 | """ 118 | -------------------------------------------------------------------------------- /commands/hastebin/command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | 5 | import requests 6 | from telegram.ext import RegexHandler 7 | 8 | from utils.decorators import send_typing_action, log_time 9 | from utils.utils import monospace 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | CODELINK_PREFIX = re.compile(r'^(@code) (?P[\s\S]+)', re.IGNORECASE) 14 | 15 | 16 | @send_typing_action 17 | @log_time 18 | def code_paster(bot, update, groupdict): 19 | code_snippet = groupdict.get('code') 20 | if not code_snippet: 21 | update.message.reply_text(f"Nop, faltó el snippet. Así: @code ") 22 | return 23 | 24 | logger.info('Posting snippet..') 25 | success, link = CodePaster.post_snippet(code_snippet) 26 | if not success: 27 | logger.info('Error posting snippet') 28 | update.message.reply_text( 29 | f"Che, no pude postearlo en hastebin.. Te lo pego en monospace a ver si te sirve\n" 30 | f"{monospace(code_snippet)}", 31 | parse_mode='markdown' 32 | ) 33 | else: 34 | update.message.reply_text(link) 35 | 36 | 37 | class CodePaster: 38 | URL = 'https://hastebin.com' 39 | ALT_URL = 'https://pastebin.com/api/api_post.php' 40 | 41 | @staticmethod 42 | def _pastebin_args(snippet): 43 | return { 44 | 'api_dev_key': os.environ['PASTEBIN'], 45 | 'api_paste_code': snippet, 46 | 'api_option': 'paste', 47 | 'api_paste_format': 'python', 48 | 'api_user_key': os.environ['PASTEBIN_PRIV'], 49 | 'api_paste_private': 2, 50 | 51 | } 52 | 53 | @classmethod 54 | def post_snippet_hastebin(cls, snippet): 55 | """Post code snippet to hastebin. 56 | 57 | Returns 58 | (bool, str): True and link if successful. False, error_msg otherwise 59 | """ 60 | try: 61 | r = requests.post(cls.URL + '/documents', data=snippet.encode('utf-8')) 62 | except Exception: 63 | return False, f'Could not post snippet to hastebin' 64 | 65 | if r.status_code == 200: 66 | try: 67 | return True, f"{cls.URL}/{r.json()['key']}" 68 | except KeyError: 69 | return False, f'json response did not include snippet key {r.json()}' 70 | except Exception: 71 | msg = f'Unknown error building link {r.url}' 72 | logger.exception(msg) 73 | return False, msg 74 | else: 75 | return False, f'Response not ok {r.status_code} - {r.reason}' 76 | 77 | @classmethod 78 | def post_snippet_pastebin(cls, snippet): 79 | try: 80 | r = requests.post(cls.ALT_URL, data=cls._pastebin_args(snippet.encode('utf-8'))) 81 | except Exception: 82 | msg = 'Error uploading snippet to pastebin.' 83 | logger.exception(msg) 84 | return False, 'Error uploading' 85 | 86 | if r.status_code == 200: 87 | return True, r.text 88 | else: 89 | return False, f'Response not ok {r.status_code} - {r.reason}' 90 | 91 | @classmethod 92 | def post_snippet(cls, snippet): 93 | success, msg = cls.post_snippet_hastebin(snippet) 94 | if not success: 95 | success, msg = cls.post_snippet_pastebin(snippet) 96 | 97 | return success, msg 98 | 99 | 100 | hastebin_handler = RegexHandler(CODELINK_PREFIX, code_paster, pass_groupdict=True) 101 | -------------------------------------------------------------------------------- /commands/feriados/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | import requests 5 | 6 | from commands.feriados.constants import month_names, FERIADOS_URL, ESP_DAY 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | def get_feriados(year, **request_args): 12 | try: 13 | url = FERIADOS_URL.format(year=year) 14 | r = requests.get(url, **request_args) 15 | logger.info(f'Retrieved feriados from {r.url}') 16 | except Exception: 17 | logger.error("Error requestion feriados", exc_info=True) 18 | return None 19 | if r.status_code != 200: 20 | logger.info(f"Response not 200. {r.status_code} {r.reason}") 21 | return None 22 | 23 | feriados = r.json() 24 | logger.info("Feriados: %s", feriados) 25 | 26 | return feriados 27 | 28 | 29 | def filter_past_feriados(today, feriados): 30 | """Returns the future feriados. Filtering past feriados.""" 31 | return ( 32 | f for f in feriados 33 | if (f['mes'], f['dia']) > (today.month, today.day) 34 | ) 35 | 36 | 37 | def next_feriado_message(today, nextest_feriado): 38 | """Get message corresponding to how many days are left until next feriado""" 39 | # Get days until next feriado 40 | next_feriado_date = datetime.datetime( 41 | day=nextest_feriado['dia'], month=nextest_feriado['mes'], year=today.year, 42 | hour=today.hour, minute=today.minute, second=min(today.second + 1, 59), 43 | tzinfo=datetime.timezone(datetime.timedelta(hours=-3)) 44 | ) 45 | 46 | # In python, timedeltas can have negative days if we do a-b and b > a). See timedelta docs for details 47 | faltan = (next_feriado_date - today) 48 | days_to_feriado = max(faltan.days, 0) 49 | 50 | if days_to_feriado == 0: 51 | feriado_msg = f"Hoy es feriado por *{nextest_feriado['motivo']}*! 🎉\n" 52 | else: 53 | feriado_msg = ( 54 | f"Faltan *{days_to_feriado} días* para el próximo feriado\n" 55 | f"- *{ESP_DAY[next_feriado_date.weekday()]} {nextest_feriado['dia']}" 56 | f" de {month_names[nextest_feriado['mes']]}* | _{nextest_feriado['motivo']}_\n" 57 | ) 58 | 59 | return feriado_msg 60 | 61 | 62 | def prettify_feriados(feriados, limit=None): 63 | """Receives a feriado generator of dict of following feriados and pretty prints them. 64 | ({ 65 | "motivo": "Año Nuevo", 66 | "tipo": "inamovible", 67 | "dia": 1, 68 | "mes": 1, 69 | "id": "año-nuevo" 70 | }, { 71 | "motivo": "Carnaval", 72 | "tipo": "inamovible", 73 | "dia": 4, 74 | "mes": 3, 75 | "id": "carnaval" 76 | }, 77 | ... 78 | ) 79 | Output: 80 | 👉 25 de diciembre - Navidad | inamovible 81 | 👉 31 de diciembre - Feriado Puente Turístico | puente 82 | """ 83 | res = '' 84 | for feriado in list(feriados)[:limit]: 85 | fecha = f"{feriado['dia']} de {month_names[feriado['mes']]}" 86 | res += f"👉 *{fecha}* - {feriado['motivo']} | _{feriado['tipo']}_\n" 87 | 88 | return res 89 | 90 | 91 | def read_limit_from_args(args): 92 | """Limit the amount of feriados to show.""" 93 | DEFAULT = 10 94 | 95 | if not args: 96 | return DEFAULT 97 | elif args[0].upper() == 'ALL': 98 | # my_list[:None] == my_list 99 | return None 100 | else: 101 | try: 102 | return int(args[0]) 103 | except (TypeError, ValueError): 104 | return DEFAULT 105 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 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 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | .idea/ 163 | __pycache__/ 164 | all_users.json 165 | .env 166 | -------------------------------------------------------------------------------- /commands/tagger/all_tagger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import os 4 | import psycopg2 5 | from telegram import MessageEntity 6 | from telegram.ext import MessageHandler, Filters, CommandHandler 7 | 8 | from utils.decorators import admin_only, log_time 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | DB = os.environ['DATABASE_URL'] 13 | 14 | 15 | def tag_all(bot, update): 16 | """Reply to a message containing '@all' tagging all users so they can read the msg.""" 17 | try: 18 | with psycopg2.connect(DB) as conn: 19 | with conn.cursor() as cursor: 20 | cursor.execute("select * from t3;") 21 | users = cursor.fetchone() 22 | update.message.reply_markdown( 23 | text=users[0] if users else 'No users added to @all tag.', 24 | quote=True, 25 | ) 26 | except Exception as e: 27 | logger.exception("Error writing to db") 28 | logger.error(e) 29 | 30 | 31 | @log_time 32 | @admin_only 33 | def set_all_members(bot, update, **kwargs): 34 | """Set members to be tagged when @all keyword is used.""" 35 | msg = kwargs.get('args') 36 | if not msg: 37 | logger.info( 38 | "No users passed to set_all_members function. kwargs: %s", kwargs) 39 | return 40 | 41 | user_entities = update.message.parse_entities( 42 | [MessageEntity.MENTION, MessageEntity.TEXT_MENTION] 43 | ) 44 | updated = update_all_users(user_entities) 45 | if updated: 46 | bot.send_message( 47 | chat_id=update.message.chat_id, text='Users added to the @all tag' 48 | ) 49 | else: 50 | pass 51 | bot.send_message( 52 | chat_id=update.message.chat_id, 53 | text='Algo pasó. Hablale a @BoedoCrow y pedile que vea los logs.', 54 | ) 55 | 56 | 57 | def update_all_users(users): 58 | """Tag users whether they have username or not. 59 | 60 | Users in telegram may or may not have username. 61 | If they have, tagging them is just writing @. 62 | But if they don't one must use markdown syntax 63 | to tag them. 64 | 65 | This function transforms a text_mention 66 | or mention MessageEntity into a ready-to-be-tagged string, 67 | If they have a username, the value of users dict 68 | contains @ and that is sufficient to tag them. 69 | If they don't we must get their ids and tag them via 70 | markdown syntax [visible_name](tg://user?id=) 71 | 72 | Args: 73 | users: dict of type MessageEntity: str 74 | 75 | Returns: 76 | tagged_users list(str): List of ready-to-be-tagged users. 77 | 78 | """ 79 | # Get users mentions 80 | ready_to_be_tagged_users = [] 81 | for entity, value in users.items(): 82 | if entity['type'] == 'text_mention': 83 | ready_to_be_tagged_users.append( 84 | f"[@{entity.user.first_name}](tg://user?id={entity.user.id})" 85 | ) 86 | elif entity['type'] == 'mention': 87 | ready_to_be_tagged_users.append(value) 88 | 89 | # Save it to db 90 | users = ' '.join(ready_to_be_tagged_users) 91 | logger.info("users %r", users) 92 | try: 93 | with psycopg2.connect(DB) as conn: 94 | with conn.cursor() as curs: 95 | # Delete previous definition if any 96 | curs.execute("DELETE FROM t3;") 97 | # Add new values. 98 | curs.execute("INSERT INTO t3 (admins) VALUES (%s);", (users,)) 99 | logger.info("Users '%s' successfully added to db", users) 100 | conn.commit() 101 | success = True 102 | except Exception: 103 | logger.exception("Error writing to db") 104 | success = False 105 | 106 | return success 107 | 108 | 109 | tag_all = MessageHandler(Filters.regex(r'@all'), tag_all) 110 | edit_tag_all = CommandHandler('setall', set_all_members, pass_args=True) 111 | -------------------------------------------------------------------------------- /commands/youtube/command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import youtube_dl 5 | from telegram import ChatAction 6 | from telegram.error import NetworkError 7 | from telegram.ext import CommandHandler 8 | 9 | from utils.decorators import handle_empty_arg, send_recording_action 10 | from utils.utils import send_message_to_admin 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | FILENAME = 'Audio_Cuervot' 15 | VLC_LINK = 'https://play.google.com/store/apps/details?id=org.videolan.vlc' 16 | 17 | 18 | @send_recording_action 19 | @handle_empty_arg(required_params=('args',), error_message='Y la url del video? `/yttomp3 `', 20 | parse_mode='markdown') 21 | def youtube_to_mp3(bot, update, args): 22 | video_url = args[0] 23 | 24 | def ext(f): 25 | return f'bestaudio[ext={f}]' 26 | 27 | def format_extensions(extens): 28 | return '/'.join(map(ext, extens)) 29 | 30 | try: 31 | extensions = ('mp3', '3gp', 'aac', 'wav', 'flac', 'm4a') 32 | ydl_opts = { 33 | 'format': format_extensions(extensions), 34 | 'outtmpl': f'{FILENAME}.%(ext)s', 35 | } 36 | logger.info(f'Starting download of {video_url}..') 37 | with youtube_dl.YoutubeDL(ydl_opts) as ydl: 38 | ydl.download([video_url]) 39 | logger.info(f'Video downloaded.') 40 | 41 | except Exception: 42 | logger.error('Audio not downloaded.', exc_info=True) 43 | update.message.reply_text(f'No audio found for {video_url}') 44 | return 45 | 46 | # Download was successful, now we must open the audio file 47 | try: 48 | logger.info('Reading audio file from local storage') 49 | file, filename = get_audio_file(extensions) 50 | update.message.reply_text(f'✅ Archivo descargado. Enviando...') 51 | bot.send_chat_action(chat_id=update.message.chat_id, action=ChatAction.UPLOAD_AUDIO) 52 | logger.info(f'Filename: {filename}') 53 | if file: 54 | logger.info('Sending file to user') 55 | update.message.reply_document(document=file) 56 | update.message.reply_text(f'💬 Tip: Podés usar [VLC]({VLC_LINK}) para reproducir el audio 🎶', 57 | parse_mode='markdown', disable_web_page_preview=True) 58 | file.close() 59 | logger.info('File sent successfully') 60 | except NetworkError: 61 | logger.error('A network error occurred.', exc_info=True) 62 | update.message.reply_text(text='🚀 Hay problemas de conexión en estos momentos. Intentá mas tarde..') 63 | send_message_to_admin(bot, f'Error mandando {video_url}, {filename}') 64 | 65 | except Exception: 66 | msg = 'Error uploading file to telegram servers' 67 | logger.exception(msg), send_message_to_admin(bot, msg) 68 | update.message.reply_text(text=msg) 69 | 70 | else: 71 | if filename: 72 | try: 73 | # Remove the file we just sent, as the name is hardcoded. 74 | logger.info(f"Removing file '{filename}'") 75 | os.remove(filename) 76 | logger.info('File removed') 77 | except FileNotFoundError: 78 | msg = f'Error removing audio file. File not found {filename}' 79 | logger.error(msg), send_message_to_admin(bot, msg) 80 | except Exception: 81 | msg = f"UnknownError removing audio file. '{filename}'" 82 | logger.error(msg), send_message_to_admin(bot, msg) 83 | 84 | 85 | def get_audio_file(exts): 86 | """Youtube-dl does not return file data on success, so we must guess the file extension""" 87 | possible_filenames = (f"{FILENAME}.{ext}" for ext in exts) 88 | for filename in possible_filenames: 89 | try: 90 | return open(filename, 'rb'), filename 91 | except FileNotFoundError: 92 | continue 93 | 94 | return None, None 95 | 96 | 97 | yt_handler = CommandHandler('yttomp3', youtube_to_mp3, pass_args=True) 98 | yt_handler_alt = CommandHandler('y', youtube_to_mp3, pass_args=True) 99 | -------------------------------------------------------------------------------- /commands/subte/updates/alerts.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import requests 5 | 6 | from commands.subte.constants import SUBWAY_STATUS_OK 7 | from commands.subte.updates.utils import ( 8 | get_update_info, 9 | send_new_incident_updates, 10 | send_service_normalization_updates, 11 | prettify_updates, 12 | ) 13 | from utils.utils import send_message_to_admin 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | def subte_updates_cron(bot, job): 19 | try: 20 | status_updates = check_update() 21 | except Exception: 22 | logger.exception('An unexpected error ocurred when fetching updates.') 23 | send_message_to_admin(bot, 'An unexpected error ocurred when requesting subte updates. Please see the logs.') 24 | return 25 | if status_updates is None: 26 | logger.error("The api did not respond with status ok.") 27 | send_message_to_admin(bot, 'Metrovias api did not respond with status 200. Check it please') 28 | return 29 | 30 | context = job.context 31 | logger.info('Checking if subte status has changed..\nSTATUS_UPDATE: %s\nCONTEXT: %s', status_updates, context) 32 | if status_updates != context: 33 | logger.info('Updating subte status...') 34 | if not status_updates: 35 | # There are no incidents to report. 36 | pretty_update = SUBWAY_STATUS_OK 37 | else: 38 | pretty_update = prettify_updates(status_updates) 39 | 40 | bot.send_message(chat_id='@subtescaba', text=pretty_update) 41 | logger.info('Update message sent to channel') 42 | try: 43 | notify_suscribers(bot, status_updates, context) 44 | logger.info('Suscribers notified of line changes') 45 | except Exception: 46 | logger.error("Could not notify suscribers", exc_info=True) 47 | 48 | # Now the context must reflect the new status_updates. Update context with new incidents. 49 | job.context = status_updates 50 | logger.info('Context updated with new status updates') 51 | 52 | else: 53 | logger.info("Subte status has not changed. Not posting new reply.") 54 | 55 | 56 | def check_update(): 57 | """Returns status incidents per line. 58 | 59 | None if response code is not 200 60 | empty dict if there are no updates 61 | dict with linea as keys and incident details as values. 62 | 63 | Returns: 64 | dict|None: mapping of line incidents 65 | { 66 | 'A': 'rota', 67 | 'E': 'demorada', 68 | } 69 | """ 70 | params = { 71 | 'client_id': os.environ['CABA_CLI_ID'], 72 | 'client_secret': os.environ['CABA_SECRET'], 73 | 'json': 1, 74 | } 75 | url = 'https://apitransporte.buenosaires.gob.ar/subtes/serviceAlerts' 76 | r = requests.get(url, params=params) 77 | 78 | if r.status_code != 200: 79 | logger.info('Response failed. %s, %s' % (r.status_code, r.reason)) 80 | return None 81 | 82 | data = r.json() 83 | 84 | alerts = data['entity'] 85 | logger.info('Alerts: %s', alerts) 86 | 87 | return dict(get_update_info(alert['alert']) for alert in alerts) 88 | 89 | 90 | def notify_suscribers(bot, status_updates, context): 91 | """Notify suscribers of updates on their lines. 92 | We notify suscribers of new incidents and 93 | we notify suscribers when a line has no more incidents. That means its working normally 94 | 95 | Args: 96 | bot: telegram.bot instance 97 | status_updates (dict): New incidents reported by the api 98 | context (dict): Incidents last time we checked 99 | """ 100 | # Send updates of new incidents 101 | sent_updates = send_new_incident_updates(bot, context, status_updates) 102 | logger.info(f'New incidents messages sent: {sent_updates}') 103 | 104 | sent_normalization_msgs = send_service_normalization_updates(bot, context, status_updates) 105 | logger.info(f'Service normalization messages sent: {sent_normalization_msgs}') 106 | -------------------------------------------------------------------------------- /commands/serie/command.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | import requests 5 | from telegram.ext import run_async, CommandHandler 6 | 7 | from commands.serie.keyboard import serie_main_keyboard 8 | from commands.serie.utils import prettify_serie 9 | from utils.decorators import send_typing_action, log_time 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @log_time 15 | @send_typing_action 16 | @run_async 17 | def serie(bot, update, chat_data, args): 18 | if not args: 19 | bot.send_message( 20 | chat_id=update.message.chat_id, 21 | text='Te faltó pasarme el nombre de la serie. `/serie `', 22 | parse_mode='markdown', 23 | ) 24 | return 25 | 26 | # Obtener id de imdb 27 | serie_query = ' '.join(args) 28 | params = {'api_key': os.environ['TMDB_KEY'], 'query': serie_query} 29 | r = requests.get('https://api.themoviedb.org/3/search/tv', params=params) 30 | if r.status_code != 200: 31 | bot.send_message( 32 | chat_id=update.message.chat_id, 33 | text=f"No encontré información en imdb sobre '{serie_query}'. Está bien escrito el nombre?", 34 | ) 35 | return 36 | 37 | try: 38 | serie = r.json()['results'][0] 39 | except (KeyError, IndexError): 40 | bot.send_message( 41 | chat_id=update.message.chat_id, 42 | text=f"No encontré resultados en imdb sobre '{serie_query}'", 43 | ) 44 | return 45 | 46 | # Send basic info to user 47 | serie_id = serie['id'] 48 | name = serie['name'] 49 | start_date = serie.get('first_air_date').split('-')[0] if serie['first_air_date'] else '' # 2014-12-28-> 2014 or '' 50 | rating = serie['vote_average'] 51 | overview = serie['overview'] 52 | image = f"http://image.tmdb.org/t/p/original{serie['backdrop_path']}" 53 | response = prettify_serie(name, rating, overview, start_date) 54 | 55 | # We reply here with basic info because further info may take a while to process. 56 | bot.send_photo(update.message.chat_id, image) 57 | bot_reply = bot.send_message( 58 | chat_id=update.message.chat_id, text=response, parse_mode='markdown' 59 | ) 60 | 61 | # Retrieve imdb_id for further requests on button callbacks 62 | params.pop('query') 63 | r_id = requests.get( 64 | f'https://api.themoviedb.org/3/tv/{serie_id}/external_ids', params=params 65 | ) 66 | if r_id.status_code != 200: 67 | logger.info( 68 | f"Request for imdb id was not succesfull. {r_id.reason} {r_id.status_code} {r_id.url}" 69 | ) 70 | bot.send_message( 71 | chat_id=update.message.chat_id, 72 | text='La api de imdb se puso la gorra 👮', 73 | parse_mode='markdown', 74 | ) 75 | return 76 | 77 | try: 78 | imdb_id = r_id.json()['imdb_id'].replace('t', '') # tt -> 79 | except KeyError: 80 | logger.info("imdb id for the movie not found") 81 | bot.send_message( 82 | chat_id=update.message.chat_id, 83 | text='No encontré el id de imdb de esta pelicula', 84 | parse_mode='markdown', 85 | ) 86 | return 87 | 88 | # Build context based on the imdb_id 89 | chat_data['context'] = { 90 | 'data': { 91 | 'imdb_id': imdb_id, 92 | 'series_name': name, 93 | 'series_raw_name': serie_query, 94 | 'message_info': (name, rating, overview, start_date), 95 | }, 96 | } 97 | 98 | # Now that i have the imdb_id, show buttons to retrieve extra info. 99 | keyboard = serie_main_keyboard(imdb_id) 100 | bot.edit_message_reply_markup( 101 | chat_id=bot_reply.chat_id, 102 | message_id=bot_reply.message_id, 103 | text=bot_reply.caption, 104 | reply_markup=keyboard, 105 | parse_mode='markdown', 106 | disable_web_page_preview=True, 107 | ) 108 | 109 | 110 | serie_handler = CommandHandler('serie', serie, pass_args=True, pass_chat_data=True) 111 | -------------------------------------------------------------------------------- /commands/snippets/command.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | from telegram.ext import run_async, RegexHandler, CommandHandler 3 | 4 | from commands.snippets.constants import SAVE_REGEX, GET_REGEX, DELETE_REGEX 5 | from commands.snippets.utils import ( 6 | lookup_content, 7 | save_to_db, 8 | select_all_snippets, 9 | remove_snippet, 10 | link_key) 11 | from utils.decorators import send_typing_action, log_time, admin_only, requires_auth 12 | 13 | 14 | @log_time 15 | @send_typing_action 16 | @run_async 17 | @requires_auth 18 | def save_snippet(bot, update, **kwargs): 19 | key = kwargs['groupdict'].get('key') 20 | content = kwargs['groupdict'].get('content') 21 | 22 | if None in (key, content): 23 | message = 'No respetaste el formato. Intentá de nuevo' 24 | else: 25 | sucess, error_message = save_to_db(key, content) 26 | if sucess: 27 | message = f'Info guardada ✅.\nPodés leerla escribiendo `@get {key}`' 28 | else: 29 | message = error_message.format(key) 30 | 31 | bot.send_message( 32 | chat_id=update.message.chat_id, text=message, parse_mode='markdown' 33 | ) 34 | 35 | 36 | @log_time 37 | @send_typing_action 38 | @run_async 39 | @requires_auth 40 | def get_snippet(bot, update, **kwargs): 41 | key = kwargs['groupdict'].get('key') 42 | content = lookup_content(key) 43 | if content: 44 | key, saved_data = content 45 | bot.send_message(chat_id=update.message.chat_id, text=saved_data) 46 | else: 47 | message = f"No hay nada guardado bajo '{key}'.\nProbá /snippets para ver qué datos están guardados" 48 | bot.send_message(chat_id=update.message.chat_id, text=message) 49 | 50 | 51 | @log_time 52 | @send_typing_action 53 | @run_async 54 | @requires_auth 55 | def get_snippet_command(bot, update, args): 56 | """Duplicate of get_snippet because only /commands can be clickable.""" 57 | if not args: 58 | update.message.reply_text('Faltó poner la clave `/get `', parse_mode='markdown') 59 | return 60 | key = ' '.join(args) 61 | content = lookup_content(key) 62 | if content: 63 | key, saved_data = content 64 | bot.send_message(chat_id=update.message.chat_id, text=saved_data) 65 | else: 66 | message = f"No hay nada guardado bajo '{key}'.\nProbá /snippets para ver qué datos están guardados" 67 | bot.send_message(chat_id=update.message.chat_id, text=message) 68 | 69 | 70 | @log_time 71 | @send_typing_action 72 | @run_async 73 | @requires_auth 74 | def show_snippets(bot, update): 75 | answers = select_all_snippets() 76 | if answers: 77 | keys = [f'🔑 {link_key(key)}' for id, key, content in answers] 78 | reminder = ['Para ver algún snippet » `/get ` o\nclickeá la clave y reenviá a un chat donde esté yo'] 79 | update.message.reply_text(text='\n\n'.join(keys + reminder), parse_mode='markdown') 80 | else: 81 | update.message.reply_text( 82 | 'No hay ningún snippet guardado!\nPodés empezar usando `#key snippet_to_save`', 83 | parse_mode='markdown', 84 | ) 85 | 86 | 87 | @run_async 88 | @log_time 89 | @admin_only 90 | def delete_snippet(bot, update, **kwargs): 91 | key = kwargs['groupdict'].get('key') 92 | if not key: 93 | update.message.reply_text('Te faltó poner qué snippet borrar') 94 | return 95 | 96 | removed = remove_snippet(key) 97 | if removed: 98 | message = f"✅ El snippet `{key}` fue borrado" 99 | else: 100 | message = 'No se pudo borrar la pregunta.' 101 | 102 | update.message.reply_text(message, parse_mode='markdown') 103 | 104 | 105 | save_snippet_handler = RegexHandler(SAVE_REGEX, save_snippet, pass_groupdict=True) 106 | get_snippet_handler = RegexHandler(GET_REGEX, get_snippet, pass_groupdict=True) 107 | delete_snippet_handler = RegexHandler(DELETE_REGEX, delete_snippet, pass_groupdict=True) 108 | snippet_get_command = CommandHandler('get', get_snippet_command, pass_args=True) 109 | show_snippets_handler = CommandHandler('snippets', show_snippets) 110 | -------------------------------------------------------------------------------- /commands/pelicula/callback.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import random 4 | 5 | import requests 6 | from telegram.ext import CallbackQueryHandler 7 | 8 | from commands.pelicula.constants import ( 9 | IMDB, 10 | YOUTUBE, 11 | TORRENT, 12 | SINOPSIS, 13 | NO_TRAILER_MESSAGE, 14 | SUBTITLES, 15 | LOADING_GIFS, 16 | PELICULA_REGEX) 17 | from commands.pelicula.keyboard import pelis_keyboard 18 | from commands.pelicula.utils import ( 19 | get_yts_torrent_info, 20 | get_yt_trailer, 21 | prettify_basic_movie_info, 22 | search_movie_subtitle, 23 | send_subtitle, 24 | ) 25 | from utils.constants import IMDB_LINK 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | def pelicula_callback(bot, update, chat_data): 31 | context = chat_data.get('context') 32 | if not context: 33 | user = update.effective_user.first_name 34 | message = ( 35 | f"Perdón {user}, no pude traer la info que me pediste.\n" 36 | f"Probá invocando de nuevo el comando a ver si me sale 😊" 37 | ) 38 | bot.send_message( 39 | chat_id=update.callback_query.message.chat_id, 40 | text=message, 41 | parse_mode='markdown', 42 | ) 43 | # Notify telegram we have answered 44 | update.callback_query.answer(text='') 45 | return 46 | 47 | answer = update.callback_query.data 48 | logger.info('User choice: %s', answer) 49 | response = handle_answer(bot, update, context['data'], answer) 50 | if response: 51 | update.callback_query.answer(text='') 52 | message, image = prettify_basic_movie_info( 53 | context['data']['movie_basic'], with_overview=False 54 | ) 55 | updated_message = '\n'.join((message, response)) 56 | 57 | update.callback_query.message.edit_text( 58 | text=updated_message, 59 | reply_markup=pelis_keyboard(include_desc=True), 60 | parse_mode='markdown', 61 | quote=False, 62 | ) 63 | else: 64 | logger.info( 65 | "Handled response: %s. Answer: %s, context: %s", 66 | response, 67 | answer, 68 | context['data'], 69 | ) 70 | 71 | 72 | def handle_answer(bot, update, data, link_choice): 73 | """Gives link_choice of movie id. 74 | 75 | link_choice in ('IMDB', 'Magnet', 'Youtube', 'Subtitles') 76 | """ 77 | params = {'api_key': os.environ['TMDB_KEY'], 'append_to_response': 'videos'} 78 | r = requests.get(f"https://api.themoviedb.org/3/movie/{data['movie']['id']}", params=params) 79 | movie_data = r.json() 80 | imdb_id = movie_data['imdb_id'] 81 | 82 | if link_choice == IMDB: 83 | answer = f"[IMDB]({IMDB_LINK.format(imdb_id)}" 84 | 85 | if link_choice == SINOPSIS: 86 | pelicula = data['movie_basic'] 87 | answer = pelicula.overview 88 | 89 | elif link_choice == YOUTUBE: 90 | trailer = get_yt_trailer(movie_data['videos']) 91 | answer = f"[Trailer]({trailer})" if trailer else NO_TRAILER_MESSAGE 92 | 93 | elif link_choice == SUBTITLES: 94 | gif = random.choice(LOADING_GIFS) 95 | logger.info("Gif elegido: %s", gif) 96 | update.callback_query.answer(text='') 97 | loading_message = bot.send_animation( 98 | chat_id=update.callback_query.message.chat_id, 99 | animation=gif, 100 | caption='Buscando subtitulos..', 101 | quote=False, 102 | ) 103 | 104 | title = data['movie']['title'] 105 | orig_title = data['movie'].get('original_title', title) 106 | sub = search_movie_subtitle(orig_title) 107 | # Send the subtitle or the error message 108 | send_subtitle(bot, update, sub, loading_message, title) 109 | answer = None 110 | 111 | elif link_choice == TORRENT: 112 | torrent = get_yts_torrent_info(imdb_id) 113 | if torrent: 114 | url, seeds, size, quality = torrent 115 | answer = ( 116 | f"📤 [{data['movie']['title']}]({url})\n\n" 117 | f"🌱 Seeds: {seeds}\n\n" 118 | f"🗳 Size: {size}\n\n" 119 | f"🖥 Quality: {quality}" 120 | ) 121 | else: 122 | answer = "🚧 No torrent available for this movie." 123 | 124 | return answer 125 | 126 | 127 | peliculas_callback = CallbackQueryHandler(pelicula_callback, pattern=PELICULA_REGEX, pass_chat_data=True) 128 | -------------------------------------------------------------------------------- /tests/commands/yts/test_handle_callback.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import pytest 4 | from telegram.error import TimedOut 5 | 6 | from commands.yts.callback_handler import handle_callback 7 | from commands.yts.constants import NEXT_YTS 8 | from commands.yts.utils import get_photo 9 | 10 | 11 | @pytest.fixture() 12 | def sample_movie(): 13 | return { 14 | "id": 9397, 15 | "url": "https://yts.am/movie/rabbit-2017", 16 | "imdb_code": "tt3415358", 17 | "title": "Rabbit", 18 | "title_english": "Rabbit", 19 | "title_long": "Rabbit (2017)", 20 | "slug": "rabbit-2017", 21 | "year": 2017, 22 | "rating": 6.2, 23 | "runtime": 0, 24 | "genres": ["Thriller"], 25 | "summary": "After a vivid dream, Maude Ashton returns to Adelaide, certain she now knows the whereabouts of her missing twin sister.", 26 | "description_full": "After a vivid dream, Maude Ashton returns to Adelaide, certain she now knows the whereabouts of her missing twin sister.", 27 | "synopsis": "After a vivid dream, Maude Ashton returns to Adelaide, certain she now knows the whereabouts of her missing twin sister.", 28 | "yt_trailer_code": "o-kjbU8lWL8", 29 | "language": "English", 30 | "mpa_rating": "", 31 | "background_image": "https://yts.am/assets/images/movies/rabbit_2017/background.jpg", 32 | "background_image_original": "https://yts.am/assets/images/movies/rabbit_2017/background.jpg", 33 | "small_cover_image": "https://yts.am/assets/images/movies/rabbit_2017/small-cover.jpg", 34 | "medium_cover_image": "https://yts.am/assets/images/movies/rabbit_2017/medium-cover.jpg", 35 | "large_cover_image": "https://yts.am/assets/images/movies/rabbit_2017/large-cover.jpg", 36 | "state": "ok", 37 | "torrents": [ 38 | { 39 | "url": "https://yts.am/torrent/download/035AF68CEF3D90223BD6B0AF7749D758E3758C32", 40 | "hash": "035AF68CEF3D90223BD6B0AF7749D758E3758C32", 41 | "quality": "720p", 42 | "seeds": 288, 43 | "peers": 207, 44 | "size": "845.33 MB", 45 | "size_bytes": 886392750, 46 | "date_uploaded": "2018-10-21 08:43:31", 47 | "date_uploaded_unix": 1540104211, 48 | }, 49 | { 50 | "url": "https://yts.am/torrent/download/73FE4BD77A20FEE5185FC509A4F5BD6B4B814588", 51 | "hash": "73FE4BD77A20FEE5185FC509A4F5BD6B4B814588", 52 | "quality": "1080p", 53 | "seeds": 117, 54 | "peers": 134, 55 | "size": "1.6 GB", 56 | "size_bytes": 1717986918, 57 | "date_uploaded": "2018-10-21 10:14:26", 58 | "date_uploaded_unix": 1540109666, 59 | }, 60 | ], 61 | "date_uploaded": "2018-10-21 08:43:31", 62 | "date_uploaded_unix": 1540104211, 63 | } 64 | 65 | 66 | def test_get_photo_retry_works(mocker, caplog): 67 | caplog.set_level(logging.INFO) 68 | mocker.patch( 69 | 'commands.yts.utils.InputMediaPhoto', side_effect=[TimedOut, 'photo_url'] 70 | ) 71 | assert get_photo('url_img') == 'photo_url' 72 | assert 'Retrying..' in caplog.text 73 | 74 | 75 | def test_get_photo_returns_none_on_timeout(mocker, caplog): 76 | caplog.set_level(logging.INFO) 77 | mocker.patch('commands.yts.utils.InputMediaPhoto', side_effect=TimedOut) 78 | assert get_photo('url_img') is None 79 | assert 'Retry Failed.' in caplog.text 80 | 81 | 82 | def test_handle_callback_with_timeout_sends_message(mocker, sample_movie, caplog): 83 | bot, update = mocker.MagicMock(), mocker.MagicMock() 84 | update.callback_query.data = NEXT_YTS 85 | mocker.patch('commands.yts.utils.InputMediaPhoto', side_effect=TimedOut) 86 | chat_data = { 87 | 'context': { 88 | 'data': [sample_movie, sample_movie], 89 | 'movie_number': 0, 90 | 'movie_count': 1, 91 | 'command': 'yts', 92 | 'edit_original_text': True, 93 | } 94 | } 95 | caplog.set_level(logging.INFO) 96 | handle_callback(bot, update, chat_data) 97 | assert bot.send_message.call_count == 1 98 | assert ( 99 | bot.send_message.call_args[1]['text'] 100 | == 'Request for new photo timed out. Try again.' 101 | ) 102 | assert "Could not build InputMediaPhoto from url" in caplog.text 103 | -------------------------------------------------------------------------------- /commands/pelicula/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections import namedtuple 3 | 4 | import requests 5 | import logging 6 | 7 | from babelfish import Language 8 | from subliminal import download_best_subtitles, save_subtitles, Movie 9 | from subliminal.subtitle import get_subtitle_path 10 | 11 | from commands.pelicula.constants import SUBS_DIR 12 | from commands.serie.utils import rating_stars 13 | from utils.constants import YT_LINK 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | Pelicula = namedtuple( 18 | 'Pelicula', ['title', 'original_title', 'rating', 'overview', 'year', 'image'] 19 | ) 20 | 21 | 22 | def request_movie(pelicula_query): 23 | params = { 24 | 'api_key': os.environ['TMDB_KEY'], 25 | 'query': pelicula_query, 26 | 'language': 'es-AR', 27 | } 28 | r = requests.get('https://api.themoviedb.org/3/search/movie', params=params) 29 | if r.status_code == 200: 30 | try: 31 | return r.json()['results'][0] 32 | except (IndexError, KeyError): 33 | return None 34 | 35 | 36 | def get_basic_info(movie): 37 | title = movie['title'] 38 | original_title = movie.get('original_title') 39 | rating = movie['vote_average'] 40 | overview = movie['overview'] 41 | year = movie['release_date'].split('-')[0] # "2016-07-27" -> 2016 42 | image_link = movie['backdrop_path'] 43 | poster = f"http://image.tmdb.org/t/p/original{image_link}" if image_link else None 44 | return Pelicula(title, original_title, rating, overview, year, poster) 45 | 46 | 47 | def prettify_basic_movie_info(peli, with_overview=True): 48 | stars = rating_stars(peli.rating) 49 | overview = peli.overview if with_overview else '' 50 | title = _title_header(peli) 51 | return ( 52 | f"{title}" 53 | f"{stars}\n\n" 54 | f"{overview}" 55 | ), peli.image 56 | 57 | def _title_header(peli): 58 | if peli.original_title: 59 | return f"{peli.title} ({peli.original_title}) ~ {peli.year}\n" 60 | else: 61 | return f"{peli.title} ({peli.year})\n" 62 | 63 | 64 | def get_yt_trailer(videos): 65 | try: 66 | key = videos['results'][-1]['key'] 67 | except (KeyError, IndexError): 68 | return None 69 | 70 | return YT_LINK.format(key) 71 | 72 | 73 | def get_yts_torrent_info(imdb_id): 74 | yts_api = 'https://yts.am/api/v2/list_movies.json' 75 | try: 76 | r = requests.get(yts_api, params={"query_term": imdb_id}) 77 | except requests.exceptions.ConnectionError: 78 | logger.info("yts api no responde.") 79 | return None 80 | if r.status_code == 200: 81 | torrent = r.json() # Dar url en lugar de hash. 82 | try: 83 | movie = torrent["data"]["movies"][0]['torrents'][0] 84 | url = movie['url'] 85 | seeds = movie['seeds'] 86 | size = movie['size'] 87 | quality = movie['quality'] 88 | 89 | return url, seeds, size, quality 90 | 91 | except (IndexError, KeyError): 92 | logger.exception("There was a problem with yts api response") 93 | return None 94 | 95 | 96 | def search_movie_subtitle(serie_episode): 97 | video = Movie.fromname(serie_episode) 98 | subtitles = download_best_subtitles({video}, {Language('spa')}) 99 | 100 | try: 101 | best_sub = subtitles[video][0] 102 | except IndexError: 103 | logger.info("No subs found for %s. Subs", serie_episode, subtitles) 104 | return None 105 | 106 | saved_subs = save_subtitles(video, [best_sub], directory=SUBS_DIR) 107 | if saved_subs: 108 | sub_filename = get_subtitle_path(video.name, language=Language('spa')) 109 | return os.path.join(SUBS_DIR, sub_filename) 110 | else: 111 | return None 112 | 113 | 114 | def send_subtitle(bot, update, sub, loading_message, title): 115 | """Reply with the subtitle if found, else send error message""" 116 | chat_id = update.callback_query.message.chat_id 117 | if sub is None: 118 | logger.info("No subtitle found for the movie") 119 | bot.delete_message(chat_id=chat_id, message_id=loading_message.message_id) 120 | update.effective_message.reply_text( 121 | f'No encontré subs para `{title}`', parse_mode='markdown' 122 | ) 123 | 124 | bot.delete_message(chat_id=chat_id, message_id=loading_message.message_id) 125 | logger.info("Deleted loading message") 126 | bot.send_document( 127 | chat_id=chat_id, 128 | document=open(sub, 'rb') 129 | ) 130 | logger.info("Subtitle file sent") 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CuervoBot 2 | CuervoBot is a Bot that learns new commands as i get more things to study. Studying sometimes is boring. Coding is always fun (and a good excuse to not feel guilty about not studying). 3 | 4 | It can set reminders, search series or movies for you, tell you if you should take the subway or not, and even help you solve system of linear equations! 5 | 6 | 7 | ## Installation 8 | First clone the project and install requirements. 9 | ```bash 10 | $ git clone https://github.com/Ambro17/AmbroBot.git 11 | $ cd AmbroBot 12 | $ pip install -r requirements.txt 13 | ``` 14 | 15 | Then open telegram and chat `@BotFather` to get a bot token. Once you have it add `PYTEL` environment variable with the token. 16 | For example, if you use Linux with zsh shell you should add this line to the end of your ~/.zshrc file 17 | 18 | `export PYTEL=` 19 | 20 | If you are on windows you can do it with a GUI, or with powershell. See [this link](https://superuser.com/questions/949560/how-do-i-set-system-environment-variables-in-windows-10) for instructions 21 | 22 | Additional environment variables are needed for the bot to work properly. 23 | 24 | ``` 25 | TMDB_KEY # To access movies and series 26 | DATABASE_URL # Database to persist information 27 | CABA_SECRET # Transport updates 28 | CABA_CLI_ID # Transport updates 29 | ``` 30 | 31 | Once you have all packages installed and all environment variables set you can run the bot with 32 | 33 | `$ python3 main.py` 34 | 35 | 36 | ## Bot in action 37 | 38 | #### Search series by name 39 | `/serie Sherlock` 40 | 41 | ![Serie output](https://i.imgur.com/Kx0bvyz.jpg "Sherlock") 42 | 43 | 44 | 45 | #### Search movie by name 46 | `/pelicula The Matrix 2` 47 | 48 | ![Movie output](https://i.imgur.com/mWRG1HH.jpg "Matrix") 49 | 50 | 51 | 52 | #### Get yts latest movies 53 | `/yts` 54 | 55 | ![Yts movies](https://i.imgur.com/wpq84zo.jpg "Yts") 56 | 57 | 58 | 59 | #### Get subte status of Buenos Aires City 60 | `/subte` 61 | 62 | ![Subte status](https://i.imgur.com/Z0Aacyd.png "Subte") 63 | 64 | or if you want to receive updates without needing to ask every time.. 65 | `/suscribe ` 66 | 67 | 68 | 69 | ## Commands List 70 | ```/partido``` 71 | 72 | Outputs San Lorenzo's next match 73 | 74 | ```/dolar``` 75 | 76 | Outputs USD->ARS exchange rates from different banks. 77 | 78 | ```/remind ``` 79 | 80 | Set reminders of todo tasks with recurrent notifications 81 | 82 | ```/rofex``` 83 | 84 | Outputs the rofex expected USD->ARS exchange rate in the following months 85 | 86 | ```/posiciones``` 87 | 88 | Outputs Liga Argentina standings 89 | 90 | ```/subte``` 91 | 92 | Outputs status of CABA subway lines. 93 | 94 | ```/cartelera``` 95 | 96 | Outputs the most popular movies available at the cinemas 97 | 98 | ```/hoypido``` 99 | 100 | Outputs hoypido food offers of the week 101 | 102 | ```/pelicula ``` 103 | 104 | Outputs rating, description and imdb, yt and .torrent links to the requested movie. 105 | 106 | ```/serie ``` 107 | 108 | Outputs all series episodes along with small description of the series. 109 | 110 | ```/yts``` 111 | 112 | Show latest movies added on yts.ag 113 | 114 | ```/feriados``` 115 | 116 | Show next feriados for Argentina 117 | 118 | ```/aproximar``` 119 | 120 | Determine solution of diagonally dominant system of linear equations via Jacobi or Gauss Seidel iterative methods 121 | 122 | `/suscribe ` 123 | 124 | Suscribe to the updates of the subway line `` on CABA. You will receive notifications of delays, suspensions, service normalization, etc. 125 | 126 | `/yttomp3` 127 | 128 | Given a youtube url, download its audio in the best quality available. Useful if you want to make your own ringtones 🎶 129 | 130 | `/feedback` 131 | 132 | Give feedback for the bot. Bugs, feature requests, questions, ideas, nuclear secrets, conspiracy theories, or whatever you feel worth sharing with the dev. 133 | 134 | 135 | ## Extra for the curious 136 | Feedback was received from diffferent users that use AmbroBot source code to create nice keyboard layouts/more complex callback interactions. As a consecuence, i decided to leave master branch as is with no further changes as it uses the ptb api 'the right way'. Nevertheless, on style-improvements branch you can peek on a custom decorator that automagically adds handlers just by decorating handler functions avoiding boilerplate code. It's really handy! 137 | 138 | ## Credits 139 | 140 | Feel free to modify this code to suit your needs. If you get inspired by this bot please reference this repo as source of inspiration. ⭐️ Stars, 🐞 issues and 🔀 PRs are appreciated! 141 | -------------------------------------------------------------------------------- /commands/yts/callback_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from telegram.ext import CallbackQueryHandler 4 | 5 | from commands.yts.constants import ( 6 | NEXT_YTS, 7 | YTS_TORRENT, 8 | YTS_FULL_DESC, 9 | MEDIA_CAPTION_LIMIT, 10 | YTS_REGEX) 11 | from commands.yts.utils import ( 12 | get_torrents, 13 | prettify_torrent, 14 | get_minimal_movie, 15 | prettify_yts_movie, 16 | get_photo, 17 | ) 18 | from keyboards.keyboards import yts_navigator_keyboard 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | def handle_callback(bot, update, chat_data): 24 | # Get the handler based on the commands 25 | context = chat_data.get('context') 26 | if not context: 27 | message = ( 28 | f"Mmm me falta info para resolver lo que pediste 🤔\n" 29 | f"Probá invocando el comando de nuevo" 30 | ) 31 | logger.info(f"Conflicting update: '{update.to_dict()}'. Chat data: {chat_data}") 32 | bot.send_message( 33 | chat_id=update.callback_query.message.chat_id, 34 | text=message, 35 | parse_mode='markdown', 36 | ) 37 | # Notify telegram we have answered 38 | update.callback_query.answer(text='') 39 | return 40 | 41 | movies = context['data'] 42 | current_movie = context['movie_number'] 43 | next_movie = current_movie + 1 44 | 45 | # Get user selection 46 | answer = update.callback_query.data 47 | if answer == NEXT_YTS: 48 | # User wants to see info for next movie 49 | try: 50 | movie = movies[next_movie] 51 | logger.info( 52 | f"Requested next movie '{movie['title_long']}' ({next_movie}/{context['movie_count']})" 53 | ) 54 | except IndexError: 55 | update.callback_query.answer( 56 | text="That's it! No more movies to peek", show_alert=True 57 | ) 58 | logger.info( 59 | f"No more movies found. Movies count: {context['movie_count']}, Requested movie index: {next_movie}" 60 | ) 61 | update.callback_query.edit_message_reply_markup( 62 | reply_markup=yts_navigator_keyboard(show_next=False) 63 | ) 64 | return 65 | 66 | update.callback_query.answer(text='Loading next movie from yts..') 67 | title, synopsis, rating, imdb, yt_trailer, image = get_minimal_movie(movie) 68 | 69 | movie_desc = prettify_yts_movie(title, synopsis, rating) 70 | 71 | # Notify that api we have succesfully handled the query 72 | update.callback_query.answer(text='') 73 | # Update current movie to next_movie 74 | context['movie_number'] = next_movie 75 | 76 | # Rebuild the same keyboard 77 | yts_navigator = yts_navigator_keyboard(imdb_id=imdb, yt_trailer=yt_trailer) 78 | 79 | photo = get_photo(image) 80 | 81 | if photo is None: 82 | bot.send_message( 83 | chat_id=update.callback_query.message.chat_id, 84 | text='Request for new photo timed out. Try again.', 85 | ) 86 | logger.info("Could not build InputMediaPhoto from url %s", image) 87 | return 88 | 89 | # Edit message photo 90 | bot.edit_message_media( 91 | chat_id=update.callback_query.message.chat_id, 92 | message_id=update.callback_query.message.message_id, 93 | media=photo, 94 | ) 95 | # Edit message caption with new movie description 96 | update.callback_query.edit_message_caption( 97 | caption=movie_desc, # Avoid Media caption too long exception 98 | reply_markup=yts_navigator, 99 | ) 100 | 101 | elif answer == YTS_FULL_DESC: 102 | update.callback_query.answer(text='Loading full description') 103 | movie = movies[current_movie] 104 | title, synopsis, rating, imdb, yt_trailer, _ = get_minimal_movie( 105 | movie, trim_description=False 106 | ) 107 | movie_desc = prettify_yts_movie(title, synopsis, rating) 108 | update.callback_query.edit_message_caption( 109 | caption=movie_desc[:MEDIA_CAPTION_LIMIT], 110 | reply_markup=yts_navigator_keyboard(imdb_id=imdb, yt_trailer=yt_trailer), 111 | ) 112 | 113 | elif answer == YTS_TORRENT: 114 | # User chose to see torrent info 115 | update.callback_query.answer(text='Fetching torrent info') 116 | movie = movies[current_movie] 117 | torrents = get_torrents(movie) 118 | pretty_torrents = '\n'.join( 119 | prettify_torrent(movie['title_long'], torrent) for torrent in torrents 120 | ) 121 | bot.send_message( 122 | chat_id=update.callback_query.message.chat_id, 123 | text=pretty_torrents, 124 | parse_mode='markdown', 125 | ) 126 | 127 | 128 | yts_callback_handler = CallbackQueryHandler(handle_callback, pattern=YTS_REGEX, pass_chat_data=True) 129 | -------------------------------------------------------------------------------- /commands/serie/callbacks.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from telegram.ext import CallbackQueryHandler 4 | 5 | from commands.serie.constants import ( 6 | LOAD_EPISODES, 7 | LATEST_EPISODES, 8 | GO_BACK_TO_MAIN, 9 | SEASON_T, 10 | EPISODE_T, 11 | EZTV_API_ERROR, 12 | EZTV_NO_RESULTS, 13 | LOAD_MORE_LATEST, 14 | SERIE_REGEX) 15 | from commands.serie.keyboard import ( 16 | serie_go_back_keyboard, 17 | serie_episodes_keyboards, 18 | serie_season_keyboard, 19 | serie_main_keyboard, 20 | serie_load_more_latest_episodes_keyboard, 21 | ) 22 | from commands.serie.utils import ( 23 | request_eztv_torrents_by_imdb_id, 24 | prettify_serie, 25 | get_all_seasons, 26 | prettify_episodes, 27 | prettify_torrents, 28 | ) 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | def serie_callback_handler(bot, update, chat_data): 34 | context = chat_data.get('context') 35 | if not context: 36 | message = ( 37 | f"Lpm, no pude responder a tu pedido.\n" 38 | f"Probá invocando de nuevo el comando a ver si me sale 😊" 39 | ) 40 | logger.info(f"Conflicting update: '{update.to_dict()}'. Chat data: {chat_data}") 41 | bot.send_message( 42 | chat_id=update.callback_query.message.chat_id, 43 | text=message, 44 | parse_mode='markdown', 45 | ) 46 | # Notify telegram we have answered 47 | update.callback_query.answer(text='') 48 | return 49 | 50 | # Get user selection 51 | answer = update.callback_query.data 52 | if answer == LATEST_EPISODES: 53 | # Get latest episodes from eztv api 54 | update.callback_query.answer(text='Getting latest episodes.. Please be patient') 55 | imdb_id = context['data']['imdb_id'] 56 | context['data']['torrents'] = torrents = request_eztv_torrents_by_imdb_id(imdb_id) 57 | 58 | if not torrents: 59 | logger.info(f"No torrents for {context['data']['series_name']}") 60 | update.callback_query.edit_message_text( 61 | text=EZTV_NO_RESULTS, reply_markup=serie_go_back_keyboard() 62 | ) 63 | return 64 | 65 | # Show only the first (latest) 5 torrents 66 | response = prettify_torrents(torrents, limit=5) 67 | keyboard = serie_load_more_latest_episodes_keyboard() 68 | 69 | elif answer == LOAD_MORE_LATEST: 70 | # Load all episodes from api 71 | response = prettify_torrents(context['data']['torrents'], limit=None) 72 | keyboard = serie_go_back_keyboard() 73 | # Todo, do further requests to show all api results. Maybe paging results into numbers? 74 | 75 | elif answer == GO_BACK_TO_MAIN: 76 | # Remove season and episode context so we can start the search again 77 | # if the user wants to download another episode. 78 | context.pop('selected_season_episodes', None) 79 | 80 | # Resend series basic description 81 | message = context['data']['message_info'] 82 | response = prettify_serie(*message) 83 | keyboard = serie_main_keyboard(context['data']['imdb_id']) 84 | # tothink: Maybe implement relative go back. chat_data context 85 | # should be more intelligent to support that. 86 | # temp key on chat_data (active_season) that resets after each episode go back? 87 | 88 | elif answer == LOAD_EPISODES: 89 | # Load all episodes parsing eztv web page 90 | # They should be loaded by now but just in case. 91 | seasons = chat_data['context'].get('seasons') 92 | if not seasons: 93 | update.callback_query.answer( 94 | text='Loading episodes.. this may take a while' 95 | ) 96 | seasons = chat_data['context']['seasons'] = get_all_seasons( 97 | context['data']['series_name'], context['data']['series_raw_name'] 98 | ) 99 | 100 | response = 'Choose a season to see its episodes.' 101 | keyboard = serie_season_keyboard(seasons) 102 | 103 | elif answer.startswith(SEASON_T.format('')): 104 | season_choice = answer.split('_')[-1] 105 | update.callback_query.answer( 106 | text=f'Loading episodes from season {season_choice}' 107 | ) 108 | season_episodes = chat_data['context']['seasons'][int(season_choice)] 109 | chat_data['context']['selected_season_episodes'] = season_episodes 110 | response = f'Season {season_choice}, choose an episode' 111 | logger.info(f"Season %s episodes %s", season_choice, sorted(tuple(season_episodes.keys()))) 112 | keyboard = serie_episodes_keyboards(season_episodes) 113 | 114 | elif answer.startswith(EPISODE_T.format('')): 115 | episode = answer.split('_')[-1] 116 | update.callback_query.answer(text=f'Loading torrents of episode {episode}') 117 | episode_list = chat_data['context']['selected_season_episodes'][int(episode)] 118 | the_episodes = prettify_episodes(episode_list) 119 | response = the_episodes if the_episodes else 'No episodes found.' 120 | keyboard = serie_go_back_keyboard() 121 | else: 122 | response = 'Unknown button %s' % answer 123 | keyboard = serie_go_back_keyboard() 124 | logger.info("We shouldn't be here. chat_data=%s, answer=%s", chat_data, answer) 125 | 126 | update.callback_query.answer(text='') 127 | 128 | original_text = update.callback_query.message.text 129 | if response != original_text: 130 | update.callback_query.edit_message_text( 131 | text=response, reply_markup=keyboard, parse_mode='markdown' 132 | ) 133 | else: 134 | logger.info( 135 | "Selected option '%s' would leave text as it is. Ignoring to avoid exception. '%s' " 136 | % (answer, response) 137 | ) 138 | 139 | 140 | serie_callback = CallbackQueryHandler(serie_callback_handler, pattern=SERIE_REGEX, pass_chat_data=True) 141 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | from telegram.ext import ( 5 | CommandHandler, 6 | Updater, 7 | ) 8 | 9 | from callbacks.handler import callback_handler 10 | from commands.cartelera.command import cartelera_handler 11 | from commands.dolar.command import dolar_handler 12 | from commands.dolar_futuro.command import dolar_futuro_handler 13 | from commands.feriados.command import feriados_handler, proximo_feriado_handler 14 | from commands.github.command import github_handler 15 | from commands.hastebin.command import hastebin_handler 16 | from commands.hoypido.command import hoypido_handler 17 | from commands.meeting.command import show_meetings_handler, delete_meeting_handler 18 | from commands.misc.commands import code_handler, tickets_handler, generic_handler, show_source 19 | from commands.partido.command import partido_handler 20 | from commands.pelicula.callback import peliculas_callback 21 | from commands.pelicula.command import pelis, pelis_alt 22 | from commands.posiciones.command import posiciones_handler 23 | from commands.register.command import register_user, show_users_handler, authorize_handler 24 | from commands.serie.callbacks import serie_callback 25 | from commands.serie.command import serie_handler 26 | from commands.snippets.command import save_snippet_handler, get_snippet_handler, snippet_get_command, \ 27 | show_snippets_handler, delete_snippet_handler 28 | from commands.subte.command import modify_subte_freq, subte_handler 29 | from commands.subte.suscribers.command import subte_suscriptions, subte_desuscriptions, subte_show_suscribers 30 | from commands.youtube.command import yt_handler, yt_handler_alt 31 | from commands.yts.callback_handler import yts_callback_handler 32 | from commands.yts.command import yts_handler 33 | from commands.aproximacion.conversation_handler import msup_conversation 34 | from commands.feedback.command import feedback_receiver 35 | from commands.meeting.conversation_handler import meeting_conversation 36 | from commands.retro.handler import add_retro_item, show_retro_details, expire_retro_command 37 | from commands.start.command import start 38 | from commands.subte.constants import SUBTE_UPDATES_CRON 39 | from commands.subte.updates.alerts import subte_updates_cron 40 | from commands.tagger.all_tagger import tag_all, edit_tag_all 41 | from inlinequeries.snippets import inline_snippets 42 | from utils.utils import error_handler, send_message_to_admin, signal_handler 43 | from utils.constants import MINUTE 44 | 45 | logging.basicConfig(format='%(asctime)s - %(name)30s - %(levelname)8s [%(funcName)s] %(message)s', 46 | level=logging.INFO) 47 | 48 | logger = logging.getLogger(__name__) 49 | 50 | 51 | def main(): 52 | # Setup bot 53 | updater = Updater(os.environ['PYTEL'], user_sig_handler=signal_handler) 54 | dispatcher = updater.dispatcher 55 | 56 | start_handler = CommandHandler('start', start) 57 | 58 | # Add repeating jobs 59 | cron_tasks = updater.job_queue 60 | cron_tasks.run_repeating(subte_updates_cron, 61 | interval=5 * MINUTE, 62 | first=50 * MINUTE, 63 | context={}, 64 | name=SUBTE_UPDATES_CRON) 65 | 66 | 67 | # Associate commands with action. 68 | dispatcher.add_handler(feedback_receiver) 69 | dispatcher.add_handler(inline_snippets) 70 | dispatcher.add_handler(start_handler) 71 | dispatcher.add_handler(register_user) 72 | dispatcher.add_handler(show_users_handler) 73 | dispatcher.add_handler(authorize_handler) 74 | dispatcher.add_handler(partido_handler) 75 | dispatcher.add_handler(dolar_handler) 76 | dispatcher.add_handler(subte_suscriptions) 77 | dispatcher.add_handler(modify_subte_freq) 78 | dispatcher.add_handler(subte_desuscriptions) 79 | dispatcher.add_handler(subte_show_suscribers) 80 | dispatcher.add_handler(dolar_futuro_handler) 81 | dispatcher.add_handler(posiciones_handler) 82 | dispatcher.add_handler(subte_handler) 83 | dispatcher.add_handler(cartelera_handler) 84 | dispatcher.add_handler(add_retro_item) 85 | dispatcher.add_handler(show_retro_details) 86 | dispatcher.add_handler(expire_retro_command) 87 | dispatcher.add_handler(pelis) 88 | dispatcher.add_handler(pelis_alt) 89 | dispatcher.add_handler(yts_handler) 90 | dispatcher.add_handler(hoypido_handler) 91 | dispatcher.add_handler(feriados_handler) 92 | dispatcher.add_handler(proximo_feriado_handler) 93 | dispatcher.add_handler(github_handler) 94 | dispatcher.add_handler(serie_handler) 95 | dispatcher.add_handler(yt_handler) 96 | dispatcher.add_handler(yt_handler_alt) 97 | dispatcher.add_handler(code_handler) 98 | dispatcher.add_handler(save_snippet_handler) 99 | dispatcher.add_handler(get_snippet_handler) 100 | dispatcher.add_handler(snippet_get_command) 101 | dispatcher.add_handler(show_snippets_handler) 102 | dispatcher.add_handler(delete_snippet_handler) 103 | dispatcher.add_handler(tag_all) 104 | dispatcher.add_handler(show_meetings_handler) 105 | dispatcher.add_handler(delete_meeting_handler) 106 | dispatcher.add_handler(hastebin_handler) 107 | dispatcher.add_handler(tickets_handler) 108 | dispatcher.add_handler(edit_tag_all) 109 | dispatcher.add_handler(show_source) 110 | 111 | # Add callback handlers 112 | dispatcher.add_handler(serie_callback) 113 | dispatcher.add_handler(yts_callback_handler) 114 | dispatcher.add_handler(peliculas_callback) 115 | 116 | # Add Conversation handler 117 | dispatcher.add_handler(msup_conversation) 118 | dispatcher.add_handler(meeting_conversation) 119 | 120 | # Add generics 121 | dispatcher.add_handler(callback_handler) 122 | dispatcher.add_handler(generic_handler) 123 | 124 | # Add error handler 125 | dispatcher.add_error_handler(error_handler) 126 | 127 | updater.start_polling() 128 | logger.info('Listening humans as %s..' % updater.bot.username) 129 | updater.idle() 130 | logger.info('Bot stopped gracefully') 131 | 132 | 133 | if __name__ == '__main__': 134 | main() 135 | -------------------------------------------------------------------------------- /utils/decorators.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | import logging 4 | import time 5 | import inspect 6 | from functools import wraps 7 | 8 | import telegram 9 | from telegram import ChatAction 10 | 11 | from commands.register.db import authorized_user 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | def send_action(action): 17 | """Sends `action` while processing func command.""" 18 | 19 | def decorator(func): 20 | @wraps(func) 21 | def command_func(bot, update, **kwargs): 22 | bot.send_chat_action( 23 | chat_id=update.effective_message.chat_id, action=action 24 | ) 25 | return func(bot, update, **kwargs) 26 | 27 | return command_func 28 | 29 | return decorator 30 | 31 | 32 | send_typing_action = send_action(ChatAction.TYPING) 33 | send_recording_action = send_action(ChatAction.RECORD_AUDIO) 34 | send_upload_video_action = send_action(ChatAction.UPLOAD_VIDEO) 35 | send_upload_photo_action = send_action(ChatAction.UPLOAD_PHOTO) 36 | 37 | 38 | def log_time(func): 39 | @wraps(func) 40 | def wrapped_func(*args, **kwargs): 41 | start = time.time() 42 | result = func(*args, **kwargs) 43 | end = time.time() 44 | logger.info(f"[{func.__name__}] <{end-start}> {kwargs if kwargs else ''}") 45 | return result 46 | 47 | return wrapped_func 48 | 49 | 50 | def admin_only(func): 51 | @wraps(func) 52 | def restricted_func(bot, update, **kwargs): 53 | user = update.effective_user.username 54 | if user == os.environ['admin']: 55 | return func(bot, update, **kwargs) 56 | else: 57 | logger.info("User %s not authorized to perform action.", user) 58 | 59 | return restricted_func 60 | 61 | 62 | def group_only(func): 63 | # Ignore action if it doesn't happen on allowed group 64 | @wraps(func) 65 | def restricted_func(bot, update, **kwargs): 66 | id = update.effective_user.id 67 | if id in json.loads(os.environ['RETRO_USERS']): 68 | return func(bot, update, **kwargs) 69 | else: 70 | update.message.reply_text('🚫 No estás autorizado a usar este comando') 71 | logger.info( 72 | "User %s not authorized to perform action.", update.effective_user 73 | ) 74 | 75 | return restricted_func 76 | 77 | 78 | def handle_empty_arg(*, required_params, error_message='Faltó un argumento', parse_mode=None): 79 | """Shortcut function execution if any of the required_params is empty. 80 | 81 | This decorator was created because many commands should output an error message if a 82 | required argument was supplied with a empty value. By defining this decorator, we can 83 | better follow the DRY principle, by parametrizing what message will be shown if certain 84 | required parameters are empty. 85 | 86 | For example /serie must be supplied with a . So if a user calls /serie without 87 | any series_name, `error_message` will be replied on that conversation 88 | 89 | All decorated functions are expected to have a signature of a telegram bot callback. Namely: 90 | >>> def callback(bot, update, optional_arg_1, optional_arg_2, ... ) 91 | A toy example would be: 92 | >>> @handle_empty_arg('req_param') 93 | >>> def test(bot, update, req_param, a, b) 94 | pass 95 | >>>test(bot, update, '', 'a', 'b') 96 | [Out] 'Faltó un argumento' 97 | 98 | """ 99 | 100 | def decorator(func): 101 | @wraps(func) 102 | def wrapped_func(*args, **kwargs): 103 | argument_and_values = zip(inspect.signature(func).parameters, args + tuple(kwargs.values())) 104 | required_arg_is_empty = any( 105 | not value 106 | for arg, value in argument_and_values 107 | if arg in required_params 108 | ) 109 | if required_arg_is_empty: 110 | bot, update, *remainder = args 111 | return update.effective_message.reply_text(error_message, parse_mode=parse_mode) 112 | else: 113 | return func(*args, **kwargs) 114 | 115 | return wrapped_func 116 | 117 | return decorator 118 | 119 | 120 | def requires_auth(func): 121 | """Decorate functions to prevent usage by unregistered users.""" 122 | @wraps(func) 123 | def restricted_func(bot, update, **kwargs): 124 | user_id = update.effective_user.id 125 | if authorized_user(user_id): 126 | return func(bot, update, **kwargs) 127 | else: 128 | logger.info(f"{update.effective_user.name} (id={user_id})" 129 | f" wants to execute {update.effective_message.text}") 130 | update.effective_message.reply_text( 131 | 'Debés registrarte primero para usar este comando. Escribí `/register`', 132 | parse_mode='markdown', 133 | quote=False 134 | ) 135 | 136 | return restricted_func 137 | 138 | 139 | def inline_auth(func): 140 | """Allow only authorized users to user the decorated funcs""" 141 | 142 | @wraps(func) 143 | def restricted_func(bot, update, **kwargs): 144 | user_id = update.effective_user.id 145 | if authorized_user(user_id): 146 | return func(bot, update, **kwargs) 147 | else: 148 | logger.info(f"{update.effective_user.name} (id={user_id})" 149 | f" wants to inlinequery with '{update.inline_query.query}'") 150 | bot.send_message( 151 | chat_id=user_id, 152 | text='🚫 Access denied. Write `/register` to register', 153 | parse_mode='markdown', 154 | quote=False 155 | ) 156 | 157 | return restricted_func 158 | 159 | 160 | def private_chat_only(func): 161 | @wraps(func) 162 | def deco_func(bot, update, **kwargs): 163 | chat_type = update.effective_chat.type 164 | if chat_type == telegram.Chat.PRIVATE: 165 | return func(bot, update, **kwargs) 166 | else: 167 | logger.info(f"{update.effective_message.text} can only be executed on a private conversation." 168 | f" {update.effective_user.name}") 169 | update.effective_message.reply_text( 170 | 'El comando funciona solo en conversacion privada con @CuervoBot.\n' 171 | 'Clickea el username para iniciar una conversación con él', 172 | parse_mode='markdown', 173 | quote=False 174 | ) 175 | 176 | return deco_func 177 | -------------------------------------------------------------------------------- /commands/meeting/conversation_handler.py: -------------------------------------------------------------------------------- 1 | import random 2 | from datetime import datetime as d, timedelta 3 | 4 | import dateparser 5 | from telegram import ReplyKeyboardRemove 6 | from telegram.ext import ( 7 | CommandHandler, 8 | MessageHandler, 9 | Filters, 10 | ConversationHandler, 11 | CallbackQueryHandler, 12 | run_async) 13 | 14 | import logging 15 | 16 | from commands.meeting.constants import MEETING_FILTER, CANCEL, time_delta_map 17 | from commands.meeting.keyboard import repeat_interval_keyboard 18 | from commands.meeting.db_operations import save_meeting 19 | from utils.decorators import group_only, send_typing_action, log_time 20 | 21 | logger = logging.getLogger(__name__) 22 | 23 | PARSE_HORARIO, SET_MEETING_JOB = range(2) 24 | 25 | friendly_name = { 26 | 'Weekly': 'semanalmente', 27 | 'Biweekly': 'cada dos semanas', 28 | 'Monthly': 'cada mes', 29 | } 30 | 31 | 32 | # Entry point 33 | @log_time 34 | @send_typing_action 35 | @run_async 36 | @group_only 37 | def set_meeting(bot, update, chat_data, args): 38 | logger.info("[set_meeting] Waiting for user input for meeting date.") 39 | 40 | if not args: 41 | update.message.reply_text( 42 | 'Faltó el nombre de la reunión. `/meeting secreta`', 43 | parse_mode='markdown' 44 | ) 45 | return 46 | 47 | chat_data['name'] = ' '.join(args) 48 | chat_data['chat_id'] = update.effective_chat.id 49 | update.message.reply_text( 50 | "📅 Elegí el día y horario de la reunión.\n" 51 | "Podés poner algo como `Lunes 21/07 8:45` y te voy a entender.", 52 | parse_mode='markdown' 53 | ) 54 | return PARSE_HORARIO 55 | 56 | 57 | # First state 58 | def set_date(bot, update, chat_data): 59 | logger.info("[set_date] Parsing user input into a meeting date.") 60 | buenos_aires_date = dateparser.parse(update.message.text, settings={'PREFER_DATES_FROM': 'future'}) 61 | utc_date = buenos_aires_date + timedelta(hours=3) # Todo: Fix lib to enable tz aware jobs 62 | 63 | if not utc_date: 64 | logger.info("[set_date] Error detecting date from user string") 65 | update.message.reply_text( 66 | "No pude interpretar la fecha. Volvé a intentar con un formato más estándar." 67 | ) 68 | return 69 | 70 | elif utc_date < d.now(): 71 | logger.info("[set_date] Date can't be earlier than current time.") 72 | update.message.reply_text( 73 | "La fecha debe ser una fecha futura.\nLos viajes en el tiempo aún no están soportados.\n" 74 | "Intentá de nuevo" 75 | ) 76 | return 77 | 78 | logger.info("[set_date] Parsed date : %s. UTC: %s", buenos_aires_date, utc_date) 79 | 80 | bs_as_date_string = buenos_aires_date.strftime('%A %d/%m %H:%M') 81 | chat_data['date_buenos_aires'] = bs_as_date_string 82 | chat_data['datetime_utc'] = utc_date 83 | 84 | logger.info(f"[set_date] Horario elegido. {bs_as_date_string}") 85 | 86 | update.message.reply_text( 87 | f'Perfecto, la próxima reunión será el: `{bs_as_date_string.capitalize()}`\n' 88 | 'Cada cuanto se va a repetir esta reunión?', 89 | parse_mode='markdown', 90 | reply_markup=repeat_interval_keyboard() 91 | ) 92 | 93 | return SET_MEETING_JOB 94 | 95 | 96 | # Second state 97 | def set_meeting_job(bot, update, chat_data, job_queue): 98 | logger.info("[set_meeting_job] Setting cron job of meeting.") 99 | 100 | if update.callback_query.data == CANCEL: 101 | update.callback_query.answer(text='') 102 | update.effective_message.edit_text('Meeting cancelada ⛔️') 103 | logger.info("Conversation ended.") 104 | return ConversationHandler.END 105 | 106 | period_key = update.callback_query.data.split('_')[-1] 107 | time_delta = time_delta_map[period_key] 108 | frequency_friendly_name = friendly_name[period_key] 109 | 110 | # Feature: manage jobs in db to survive bot shutdown 111 | job_queue.run_repeating( 112 | send_notification, 113 | interval=time_delta, 114 | first=chat_data['datetime_utc'], 115 | context=chat_data 116 | ) 117 | logger.info("[set_meeting_job] Meeting set with datetime %s. Bs As: %s, and timedelta %s.", 118 | chat_data['datetime_utc'], chat_data['date_buenos_aires'], time_delta) 119 | 120 | # Save meeting to db 121 | try: 122 | save_meeting(chat_data['name'], chat_data['datetime_utc']) 123 | except Exception: 124 | logger.exception("Meeting could not be saved") 125 | 126 | update.callback_query.answer(text='Meeting saved') 127 | update.callback_query.message.edit_text( 128 | f"✅ Listo. La reunión `{chat_data['name']}` quedó seteada para el `{chat_data['date_buenos_aires'].capitalize()}` " 129 | f"y se repetirá `{frequency_friendly_name}`", 130 | parse_mode='markdown', 131 | reply_markup=None 132 | ) 133 | 134 | logger.info("Conversation has ended.") 135 | return ConversationHandler.END 136 | 137 | 138 | def send_notification(bot, job): 139 | logger.info("Sending notification.") 140 | emoji = random.choice("📢📣🔈🔉🔊💬🎉🎊") 141 | bot.send_message( 142 | chat_id=job.context['chat_id'], 143 | text=f"{emoji} Reunión: {job.context['name']}! @all " 144 | ) 145 | 146 | 147 | def cancel(bot, update): 148 | user = update.message.from_user 149 | logger.info("User %s canceled the conversation.", user.first_name) 150 | update.message.reply_text('Bye! I hope we can talk again some day.', 151 | reply_markup=ReplyKeyboardRemove()) 152 | 153 | return ConversationHandler.END 154 | 155 | 156 | def default_msg(bot, update): 157 | update.effective_message.reply_text('Sabés que no te entendí. Empecemos de nuevo.') 158 | return ConversationHandler.END 159 | 160 | 161 | def default(bot, update): 162 | update.callback_query.answer(text='') 163 | update.callback_query.message.edit_text( 164 | '🤕 Algo me confundió. Podemos empezar de nuevo con /meeting', 165 | reply_markup=None, 166 | ) 167 | return ConversationHandler.END 168 | 169 | 170 | meeting_conversation = ConversationHandler( 171 | entry_points=[ 172 | CommandHandler('meeting', set_meeting, pass_chat_data=True, pass_args=True) 173 | ], 174 | states={ 175 | PARSE_HORARIO: [MessageHandler(Filters.text, set_date, pass_chat_data=True)], 176 | 177 | SET_MEETING_JOB: [ 178 | CallbackQueryHandler(set_meeting_job, pattern=MEETING_FILTER, pass_chat_data=True, pass_job_queue=True), 179 | ] 180 | }, 181 | fallbacks=[ 182 | MessageHandler(Filters.all, default_msg), 183 | CallbackQueryHandler(default) 184 | ], 185 | allow_reentry=True 186 | ) 187 | -------------------------------------------------------------------------------- /commands/serie/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from collections import defaultdict 3 | from functools import lru_cache 4 | from operator import attrgetter 5 | 6 | import requests 7 | from bs4 import BeautifulSoup 8 | 9 | from commands.serie.constants import ( 10 | NAME, 11 | EPISODE_PATTERNS, 12 | MAGNET, 13 | TORRENT, 14 | SIZE, 15 | RELEASED, 16 | SEEDS, 17 | Episode, 18 | EZTVEpisode, 19 | ) 20 | from utils.utils import monospace 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def rating_stars(rating): 26 | """Transforms int rating into stars with int""" 27 | stars = int(rating // 2) 28 | rating_stars = f"{'⭐'*stars} ~ {rating}" 29 | return rating_stars 30 | 31 | 32 | @lru_cache(5) 33 | def prettify_serie(name, rating, overview, start_date): 34 | if start_date: 35 | name = f"{name} ({start_date})" 36 | stars = rating_stars(rating) 37 | return '\n'.join((name, stars, overview)) 38 | 39 | 40 | @lru_cache(20) 41 | def request_eztv_torrents_by_imdb_id(imdb_id, limit=None): 42 | """Request torrents from api and return a minified torrent representation. 43 | 44 | A torrent is a tuple of (title, url, seeds, size) 45 | """ 46 | try: 47 | r = requests.get( 48 | 'https://eztv.ag/api/get-torrents', 49 | params={'imdb_id': imdb_id, 'limit': limit}, 50 | ) 51 | torrents = r.json()['torrents'] 52 | except KeyError: 53 | logger.info("No torrents in eztv api for this serie. Response %s", r.json()) 54 | return None 55 | except Exception: 56 | logger.exception("Error requesting torrents for %s", imdb_id) 57 | return None 58 | 59 | return parse_torrents(torrents) 60 | 61 | 62 | def _read_season_episode_from_title(title): 63 | for pattern in EPISODE_PATTERNS: 64 | match = pattern.search(title) 65 | if match: 66 | season, episode = match.groups() 67 | return season, episode 68 | else: 69 | return 0, 0 70 | 71 | 72 | def parse_torrents(torrents): 73 | """Returns a torrent name, url, seeds and size from json response""" 74 | parsed_torrents = [] 75 | for torrent in torrents: 76 | try: 77 | MB = 1024 * 1024 78 | size_float = int(torrent['size_bytes']) / MB 79 | size = f"{size_float:.2f}" 80 | title = torrent['title'] 81 | season, episode = _read_season_episode_from_title(title) 82 | parsed_torrents.append( 83 | EZTVEpisode( 84 | name=title, 85 | season=season, 86 | episode=episode, 87 | torrent=torrent['torrent_url'], 88 | seeds=torrent['seeds'], 89 | size=size, 90 | ) 91 | ) 92 | except Exception: 93 | logger.exception("Error parsing torrent from eztv api. <%s>", torrent) 94 | continue 95 | 96 | # Order torrents to from latest to oldest 97 | ordered_torrents = sorted( 98 | parsed_torrents, key=attrgetter('season', 'episode'), reverse=True 99 | ) 100 | # Make it hashable so lru_cache can remember it and avoid reloading. 101 | ordered_torrents = tuple(ordered_torrents) 102 | 103 | return ordered_torrents 104 | 105 | 106 | @lru_cache(5) 107 | def prettify_torrents(torrents, limit=5): 108 | return '\n'.join(prettify_torrent(torrent) for torrent in torrents[:limit]) 109 | 110 | 111 | def prettify_torrent(torrent): 112 | return ( 113 | f"[{torrent.name}]({torrent.torrent})\n" 114 | f"🌱 Seeds: {torrent.seeds} | 🗳 Size: {torrent.size}MB\n" 115 | ) 116 | 117 | 118 | @lru_cache(20) 119 | def get_all_seasons(series_name, raw_user_query): 120 | """Parses eztv search page in order to return all episodes of a given series. 121 | 122 | Args: 123 | series_name: Full series name as it is on tmdb 124 | raw_user_query: The user query, with possible typos or the incomplete series_name 125 | 126 | Unlike get_latest_episodes handler function, this does not communicate directly 127 | with the eztv api because the api is in beta mode and has missing season and episode info 128 | for many episodes. 129 | In order to present the series episodes in an orderly manner, we need to rely on 130 | that information consistency and completeness. Neither of those requirements 131 | are satisfied by the api. That's why we parse the web to get consistent results. 132 | Quite a paradox.. 133 | 134 | 135 | Returns: 136 | { 137 | 1: # season 138 | 1: [ # episode 139 | {Episode()}, 140 | {Episode()}, 141 | ... 142 | ], 143 | 2: [ 144 | {Episode()}, 145 | {Episode()}, 146 | ... 147 | ] 148 | 2: 149 | 1: [ 150 | {Episode()}, 151 | {Episode()}, 152 | ... 153 | ], 154 | ... 155 | ... 156 | } 157 | 158 | """ 159 | series_episodes = defaultdict(lambda: defaultdict(list)) 160 | 161 | def get_link(links, key): 162 | try: 163 | link = links[key]['href'] 164 | except (IndexError, AttributeError): 165 | link = '' 166 | 167 | return link 168 | 169 | def get_episode_info(torrent): 170 | """Parse html to return an episode data. 171 | 172 | Receives an html row, iterates its tds 173 | (leaving the first and last values out). 174 | and returns an episode namedtuple 175 | """ 176 | 177 | # First cell contain useless info (link with info) 178 | torrent = torrent.find_all('td')[1:] 179 | links = torrent[1].find_all('a') 180 | name = torrent[NAME].text.strip() 181 | 182 | # Filter fake results that include series name but separated between other words. 183 | # For example, a query for The 100 also returns '*The* TV Show S07E00 Catfish 184 | # Keeps it *100*' which we don't want. We also use the raw_user_query 185 | # because sometimes the complete name from tmdb is not the same name used on eztv. 186 | if ( 187 | not series_name.lower() in name.lower() 188 | and not raw_user_query.lower() in name.lower() 189 | ): 190 | # The tradeoff is that we don't longer work for series with typos. But it's better than giving fake results. 191 | logger.info(f"Fake result '{name}' for query '{series_name}'") 192 | return None 193 | 194 | for pattern in EPISODE_PATTERNS: 195 | match = pattern.search(name) 196 | if match: 197 | season, episode = match.groups() 198 | break 199 | else: 200 | # No season and episode found 201 | logger.info(f"Could not read season and episode data from torrent '{name}'") 202 | return None 203 | 204 | return Episode( 205 | name=name.replace('[', '').replace(']', ''), 206 | season=int(season), 207 | episode=int(episode), 208 | magnet=get_link(links, MAGNET), 209 | torrent=get_link(links, TORRENT), 210 | size=torrent[SIZE].text.strip(), 211 | released=torrent[RELEASED].text.strip(), 212 | seeds=torrent[SEEDS].text.strip(), 213 | ) 214 | 215 | # Parse episodes from web 216 | series_query = raw_user_query.replace(' ', '-') 217 | r = requests.get("https://eztv.ag/search/{}".format(series_query)) 218 | soup = BeautifulSoup(r.text, 'lxml') 219 | torrents = soup.find_all('tr', {'class': 'forum_header_border'}) 220 | 221 | # Build the structured dict 222 | for torrent in torrents: 223 | episode_info = get_episode_info(torrent) 224 | if not episode_info: 225 | # We should skip torrents if they don't belong to a season 226 | continue 227 | 228 | season, episode = episode_info.season, episode_info.episode 229 | # Attach the episode under the season key, under the episode key, in a list of torrents of that episode 230 | series_episodes[season][episode].append(episode_info) 231 | 232 | logger.info( 233 | "'%s' series episodes retrieved. Seasons: %s", 234 | series_name, 235 | series_episodes.keys(), 236 | ) 237 | return series_episodes 238 | 239 | 240 | def prettify_episodes(episodes, header=None): 241 | episodes = '\n\n'.join(prettify_episode(ep) for ep in episodes) 242 | if header: 243 | episodes = '\n'.join((header, episodes)) 244 | 245 | return episodes 246 | 247 | 248 | def prettify_episode(ep): 249 | """Episodes have name, season, episode, torrent, magnet, size, seeds and released attributes""" 250 | # Some episodes do not have a torrent download. But they do have a magnet link. 251 | # Since magnet links are not clickable on telegram, we leave them as a fallback. 252 | if ep.torrent: 253 | header = f"[{ep.name}]({ep.torrent})\n" 254 | elif ep.magnet: 255 | header = f"Magnet: {monospace(ep.magnet)}" 256 | else: 257 | header = 'No torrent nor magnet available for this episode.' 258 | 259 | return f"{header}" f"🌱 Seeds: {ep.seeds} | 🗳 Size: {ep.size or '-'}" 260 | -------------------------------------------------------------------------------- /commands/aproximacion/state_handlers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import numpy as np 4 | from telegram.ext import ConversationHandler 5 | 6 | from commands.aproximacion.constants import ( 7 | JACOBI, 8 | GAUSS_SEIDEL, 9 | DETALLE, 10 | OTHER_METHOD, 11 | EXPORT_CSV, 12 | EXAMPLE_DDOM_ROW, 13 | ) 14 | from commands.aproximacion.keyboard import ( 15 | show_matrix_markup, 16 | equations_matrix_markup, 17 | aproximar_o_cancelar, 18 | see_details_or_aproximate_by_other, 19 | ) 20 | from commands.aproximacion.utils import ( 21 | _parse_matrix, 22 | _is_square, 23 | _is_diagonal_dominant, 24 | aproximate, 25 | prettify_details, 26 | opposite_method, 27 | dump_results_to_csv, 28 | ) 29 | from utils.utils import monospace 30 | from utils.decorators import send_typing_action 31 | 32 | READ_MATRIX_A, READ_MATRIX_B, SOLVE_METHOD, METHOD_PARAMETERS, APROXIMAR, DETAILS = range(6) 33 | 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | @send_typing_action 39 | def ingresar_matriz(bot, update): 40 | update.message.reply_text( 41 | 'Ingresar matriz A. El formato es:\n' + monospace(EXAMPLE_DDOM_ROW), 42 | parse_mode='markdown', 43 | ) 44 | return READ_MATRIX_A 45 | 46 | 47 | # First state 48 | def read_matriz(bot, update, chat_data): 49 | matrix = _parse_matrix(update.message.text) 50 | if not matrix: 51 | update.message.reply_text( 52 | text=f'🚫 La matriz ingresada no respeta el formato.\nIntentá de nuevo' 53 | ) 54 | return READ_MATRIX_A 55 | is_square_matrix = _is_square(matrix) 56 | is_diag_dominant = _is_diagonal_dominant(matrix) 57 | if not is_diag_dominant: 58 | update.message.reply_text( 59 | text=f'🚫 La matriz ingresada no es diagonalmente dominante.\nIntentá de nuevo' 60 | ) 61 | return READ_MATRIX_A 62 | if not is_square_matrix: 63 | update.message.reply_text( 64 | text=f'🚫 La matriz ingresada no es cuadrada.\nIntentá de nuevo' 65 | ) 66 | return READ_MATRIX_A 67 | 68 | chat_data['matrix'] = matrix 69 | logger.info("A: %s", matrix) 70 | 71 | update.message.reply_text( 72 | text=f'✅ La matriz ingresada es diagonalmente dominante:', 73 | reply_markup=show_matrix_markup(matrix), 74 | ) 75 | ejemplo = ' '.join([str(num) for num in range(1, len(matrix) + 1)]) 76 | update.message.reply_text( 77 | text=f'Ahora Ingresa la matriz de términos independientes (B).\nEjemplo `{ejemplo}`', 78 | parse_mode='markdown', 79 | ) 80 | return READ_MATRIX_B 81 | 82 | 83 | # Second state 84 | def read_coef_matrix_and_choose_method(bot, update, chat_data): 85 | b_matrix = update.message.text.split(' ') 86 | 87 | required_coefs = len(chat_data['matrix']) 88 | if len(b_matrix) != required_coefs: 89 | update.message.reply_text( 90 | f'🚫 Los coeficientes deben ser {required_coefs} pero ingresaste {len(b_matrix)}\n' 91 | 'Volvé a intentarlo separando con un espacio cada coeficiente 🙏' 92 | ) 93 | return READ_MATRIX_B 94 | 95 | chat_data['matrix_b'] = b_matrix 96 | logger.info("B: %s", b_matrix) 97 | 98 | a_matrix = chat_data['matrix'] 99 | update.message.reply_text( 100 | 'Elegí el método de resolución o apreta /cancel si ves algo mal.\n' 101 | 'El sistema de ecuaciones lineales es el siguiente:', 102 | reply_markup=equations_matrix_markup(a_matrix, b_matrix), 103 | ) 104 | return SOLVE_METHOD 105 | 106 | 107 | # Third State 108 | def solve_method(bot, update, chat_data): 109 | method = update.callback_query.data 110 | if method not in (JACOBI, GAUSS_SEIDEL): 111 | # A number, or the equal sign was pressed. Ignore it 112 | update.callback_query.answer(text='') 113 | return SOLVE_METHOD 114 | 115 | chat_data['chosen_method'] = method 116 | logger.info(f'Método: {method}') 117 | 118 | update.callback_query.answer(text='') 119 | update.callback_query.message.reply_text( 120 | f'Elegiste el metodo `{method}`.\n' 121 | 'Elegí el vector inicial, la cota de error y la cantidad de decimales.\n' 122 | 'El formato es: `0 0 0; 0.001; 4`', 123 | parse_mode='markdown', 124 | ) 125 | 126 | return METHOD_PARAMETERS 127 | 128 | 129 | # Third state message handler (not callback) 130 | def solve_method_by_text(bot, update, chat_data, groups): 131 | method = update.message.text 132 | if method in ('j', 'jacobi'): 133 | method = JACOBI 134 | chat_data['chosen_method'] = JACOBI 135 | elif method in ('g', 'gauss'): 136 | method = GAUSS_SEIDEL 137 | chat_data['chosen_method'] = method 138 | else: 139 | # Ignore update 140 | logger.info("Ignoring response %s" % method) 141 | return 142 | 143 | logger.info(f'Método: {method}') 144 | 145 | update.message.reply_text( 146 | f'Elegiste el metodo `{method}`.\n' 147 | 'Elegí el vector inicial, la cota de error y la cantidad de decimales.\n' 148 | 'El formato es: `0 0 0; 0.001; 4`', 149 | parse_mode='markdown', 150 | ) 151 | 152 | return METHOD_PARAMETERS 153 | 154 | 155 | # Fourth State 156 | def read_method_parameters(bot, update, chat_data): 157 | params = update.message.text.split(';') 158 | if len(params) != 3: 159 | update.message.reply_text( 160 | '🚫 No ingresaste bien la data. Asegurate de separar por ; (punto y coma)' 161 | ) 162 | return METHOD_PARAMETERS 163 | 164 | v_inicial, cota, cant_decimales = [p.strip() for p in params] 165 | chat_data['v_inicial'] = v_inicial 166 | 167 | try: 168 | chat_data['cant_decimales'] = int(cant_decimales) 169 | chat_data['cota'] = float(cota) 170 | except (TypeError, ValueError): 171 | update.message.reply_text( 172 | '🚫 Error al interpretar la información. Ingresaste números?' 173 | ) 174 | return METHOD_PARAMETERS 175 | 176 | logger.info(f"V_0: {v_inicial}, decimales: {cant_decimales}, cota de error: {cota}") 177 | 178 | update.message.reply_text( 179 | f"Método de resolución `{chat_data['chosen_method']}`\n" 180 | f"Vector inicial: `{v_inicial}`\n" 181 | f"Cantidad de decimales: `{cant_decimales}`\n" 182 | f"Cota de error: `{cota}`\n", 183 | parse_mode='markdown', 184 | reply_markup=aproximar_o_cancelar(), 185 | ) 186 | return APROXIMAR 187 | 188 | 189 | # Fifth State 190 | def calculate(bot, update, chat_data): 191 | if update.callback_query.data == '/cancel': 192 | update.callback_query.answer(text='') 193 | update.effective_message.reply_text('👮🏾‍♀️ Operación cancelada') 194 | return ConversationHandler.END 195 | 196 | a_matrix = chat_data['matrix'] 197 | b_matrix = list(map(int, chat_data['matrix_b'])) 198 | v_inicial = list( 199 | map(int, chat_data['v_inicial'].split(' ')) 200 | ) # '0 0 0' to [0, 0, 0] 201 | cota_de_error = chat_data['cota'] 202 | decimals = chat_data['cant_decimales'] 203 | method = chat_data['chosen_method'] 204 | try: 205 | result, details = aproximate( 206 | method, a_matrix, b_matrix, cota_de_error, v_inicial, decimals 207 | ) 208 | 209 | except ValueError: 210 | logger.exception('No se pudo calcular la inversa de la matriz') 211 | update.callback_query.message.reply_text( 212 | 'Error calculando la inversa de la matriz. Abortando..' 213 | ) 214 | return ConversationHandler.END 215 | 216 | except FloatingPointError: 217 | logger.exception('Imposible dividir por cero') 218 | update.callback_query.message.reply_text( 219 | 'No se puede dividir por 0. Operación abortada.' 220 | ) 221 | return ConversationHandler.END 222 | 223 | except Exception: 224 | logger.exception('Excepcion inesperada') 225 | update.callback_query.message.reply_text( 226 | 'Ocurrió un error en el cálculo. Abortando..' 227 | ) 228 | return ConversationHandler.END 229 | 230 | finally: 231 | update.callback_query.answer(text='') 232 | 233 | chat_data['result'] = result 234 | chat_data['result_details'] = details 235 | 236 | np.set_printoptions(precision=decimals) 237 | update.callback_query.message.reply_text( 238 | f"El resultado de la aproximación via `{chat_data['chosen_method']}` es:\n`{result}`", 239 | parse_mode='markdown', 240 | reply_markup=see_details_or_aproximate_by_other(), 241 | ) 242 | return DETAILS 243 | 244 | 245 | # 6th State 246 | def details(bot, update, chat_data): 247 | answer = update.callback_query.data 248 | update.callback_query.answer(text='') 249 | if answer == DETALLE: 250 | result_steps = chat_data['result_details'] 251 | details = prettify_details(result_steps, chat_data['cant_decimales']) 252 | update.callback_query.message.reply_text( 253 | monospace(details), parse_mode='markdown' 254 | ) 255 | elif answer == OTHER_METHOD: 256 | chat_data['chosen_method'] = opposite_method[chat_data['chosen_method']] 257 | return calculate(bot, update, chat_data) 258 | 259 | elif answer == EXPORT_CSV: 260 | csv_results = dump_results_to_csv( 261 | chat_data['result'], 262 | chat_data['result_details'], 263 | chat_data['cant_decimales'], 264 | chat_data['cota'], 265 | ) 266 | bot.send_document( 267 | chat_id=update.callback_query.message.chat_id, 268 | document=open(csv_results, 'rb'), 269 | ) 270 | else: 271 | update.callback_query.message.edit_text( 272 | '🏳 Mi trabajo aquí ha terminado', reply_markup=None 273 | ) 274 | return ConversationHandler.END 275 | 276 | 277 | def cancel(bot, update): 278 | update.effective_message.reply_text('👮🏾‍♀️ Operación cancelada') 279 | return ConversationHandler.END 280 | 281 | 282 | def default(bot, update): 283 | update.callback_query.answer(text='') 284 | update.callback_query.message.edit_text( 285 | '🤕 Algo me confundió. Podemos empezar de nuevo con /aproximar', 286 | reply_markup=None, 287 | ) 288 | return ConversationHandler.END 289 | -------------------------------------------------------------------------------- /tests/commands/subte/updates/test_alerts.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | import pytest 3 | from unittest.mock import call 4 | 5 | from commands.subte.constants import SUBWAY_ICON, SUBWAY_LINE_OK, SUBWAY_STATUS_OK 6 | from commands.subte.updates.alerts import notify_suscribers, subte_updates_cron 7 | from commands.subte.updates.utils import _get_linea_name 8 | 9 | sample_alerts = [ 10 | { 11 | 'id': 'Alert_LineaA', 12 | 'is_deleted': False, 13 | 'trip_update': None, 14 | 'vehicle': None, 15 | 'alert': { 16 | 'active_period': [], 17 | 'informed_entity': [{ 18 | 'agency_id': '', 19 | 'route_id': 'LineaA', 20 | 'route_type': 0, 21 | 'trip': None, 22 | 'stop_id': '' 23 | }], 24 | 'cause': 2, 25 | 'effect': 2, 26 | 'url': None, 27 | 'header_text': { 28 | 'translation': [{ 29 | 'text': 'Por obras en zona de vías: Servicio limitado entre Perú y San Pedrito.', 30 | 'language': 'es' 31 | }] 32 | }, 33 | 'description_text': { 34 | 'translation': [{ 35 | 'text': 'Por obras en zona de vías: Servicio limitado entre Perú y San Pedrito.', 36 | 'language': 'es' 37 | }] 38 | } 39 | } 40 | }, 41 | { 42 | 'id': 'Alert_PM-Civico', 43 | 'is_deleted': False, 44 | 'trip_update': None, 45 | 'vehicle': None, 46 | 'alert': { 47 | 'active_period': [], 48 | 'informed_entity': [{ 49 | 'agency_id': '', 50 | 'route_id': 'PM-Civico', 51 | 'route_type': 0, 52 | 'trip': None, 53 | 'stop_id': '' 54 | }], 55 | 'cause': 1, 56 | 'effect': 1, 57 | 'url': None, 58 | 'header_text': { 59 | 'translation': [{ 60 | 'text': 'Servicio interrumpido', 61 | 'language': 'es' 62 | }] 63 | }, 64 | 'description_text': { 65 | 'translation': [{ 66 | 'text': 'Servicio interrumpido', 67 | 'language': 'es' 68 | }] 69 | } 70 | } 71 | } 72 | ] 73 | sample_response_full = { 74 | 'header': { 75 | 'gtfs_realtime_version': '2.0', 76 | 'incrementality': 0, 77 | 'timestamp': 1542805109 78 | }, 79 | 'entity': [{ 80 | 'id': 'Alert_LineaA', 81 | 'is_deleted': False, 82 | 'trip_update': None, 83 | 'vehicle': None, 84 | 'alert': { 85 | 'active_period': [], 86 | 'informed_entity': [{ 87 | 'agency_id': '', 88 | 'route_id': 'LineaA', 89 | 'route_type': 0, 90 | 'trip': None, 91 | 'stop_id': '' 92 | }], 93 | 'cause': 2, 94 | 'effect': 2, 95 | 'url': None, 96 | 'header_text': { 97 | 'translation': [{ 98 | 'text': 'Por obras en zona de vías: Servicio limitado entre Perú y San Pedrito.', 99 | 'language': 'es' 100 | }] 101 | }, 102 | 'description_text': { 103 | 'translation': [{ 104 | 'text': 'Por obras en zona de vías: Servicio limitado entre Perú y San Pedrito.', 105 | 'language': 'es' 106 | }] 107 | } 108 | } 109 | }] 110 | } 111 | sample_response_no_updates = { 112 | 'header': { 113 | 'gtfs_realtime_version': '2.0', 114 | 'incrementality': 0, 115 | 'timestamp': 1545183402 116 | }, 117 | 'entity': [] 118 | } 119 | 120 | 121 | @pytest.fixture() 122 | def bot(mocker): 123 | return mocker.MagicMock(name='bot') 124 | 125 | 126 | @pytest.fixture() 127 | def job(mocker): 128 | job = mocker.MagicMock(name='job') 129 | 130 | def inner(context): 131 | job.context = context 132 | return job 133 | 134 | return inner 135 | 136 | 137 | @pytest.fixture() 138 | def linea_alert(linea_name): 139 | return { 140 | 'active_period': [], 141 | 'informed_entity': [{ 142 | 'agency_id': '', 143 | 'route_id': linea_name, 144 | 'route_type': 0, 145 | 'trip': None, 146 | 'stop_id': '' 147 | }], 148 | 'cause': 1, 149 | 'effect': 1, 150 | 'url': None, 151 | 'header_text': { 152 | 'translation': [{ 153 | 'text': 'Servicio interrumpido', 154 | 'language': 'es' 155 | }] 156 | }, 157 | 'description_text': { 158 | 'translation': [{ 159 | 'text': 'Servicio interrumpido', 160 | 'language': 'es' 161 | }] 162 | } 163 | } 164 | 165 | 166 | @pytest.mark.parametrize('linea_raw, linea', [ 167 | (linea_alert('LineaA'), 'A'), 168 | (linea_alert('LineaB'), 'B'), 169 | (linea_alert('LineaC'), 'C'), 170 | (linea_alert('LineaD'), 'D'), 171 | (linea_alert('LineaE'), 'E'), 172 | (linea_alert('LineaH'), 'H'), 173 | (linea_alert('PM-Civico'), 'PM Civico'), 174 | (linea_alert('PM-Cívico'), 'PM Cívico'), 175 | (linea_alert('PM-Savio'), 'PM Savio'), 176 | ]) 177 | def test_get_correct_line_identifier(linea_raw, linea): 178 | assert _get_linea_name(linea_raw) == linea 179 | 180 | 181 | @pytest.fixture() 182 | def suscriptor(mocker): 183 | def inner(id_): 184 | user = mocker.MagicMock(name='user') 185 | user.user_id = id_ 186 | return user 187 | 188 | return inner 189 | 190 | 191 | def test_dont_send_message_if_linea_status_has_not_changed(mocker, suscriptor): 192 | """Test that if context has already the line status, no new message is sent.""" 193 | # Setup 194 | bot = mocker.MagicMock(name='bot') 195 | context = {'A': 'broken', 'B': 'with_delays'} 196 | status_updates = dict([('A', 'broken'), ('B', 'with_delays'), ('C', 'new_update')]) 197 | mocker.patch( 198 | 'commands.subte.updates.utils.get_suscriptors_by_line', 199 | return_value=[suscriptor(id_=10), suscriptor(id_=30)] 200 | ) 201 | 202 | # Exercise 203 | notify_suscribers(bot, status_updates, context) 204 | 205 | # Validate that only messages of linea C were sent to the two suscriptors. 206 | assert bot.send_message.call_count == 2 207 | assert bot.send_message.call_args_list == [ 208 | call(chat_id=10, text=f'C | {SUBWAY_ICON}️ new_update'), 209 | call(chat_id=30, text=f'C | {SUBWAY_ICON}️ new_update') 210 | ] 211 | 212 | 213 | @pytest.mark.parametrize('context, status_updates, messages_sent', [ 214 | ({'A': 'The line was broken'}, {}, 1), 215 | ({'A': 'The line was broken', 'B': 'Broken', 'C': 'Broken'}, {}, 3), 216 | ({'D': 'suspended', 'E': 'delayed'}, {'E': 'delayed'}, 1), 217 | ] 218 | ) 219 | def test_send_update_when_line_has_resumed_normal_operation(mocker, suscriptor, context, status_updates, messages_sent): 220 | bot = mocker.MagicMock(name='bot') 221 | mocker.patch( 222 | 'commands.subte.updates.utils.get_suscriptors_by_line', 223 | return_value=[suscriptor(id_=15)] 224 | ) 225 | 226 | # Exercise 227 | notify_suscribers(bot, status_updates, context) 228 | 229 | # Validate that only messages of linea C were sent to the two suscriptors. 230 | assert bot.send_message.call_count == messages_sent 231 | # assert all sent messages were sent to notify that subway line is working as expected 232 | assert all(['funciona con normalidad' in msg_call[1]['text'] for msg_call in bot.send_message.call_args_list]) 233 | 234 | 235 | def test_notify_normalization_and_new_updates(mocker, suscriptor, bot): 236 | # Test that service normalization services are sent along with updates on other line conflicts 237 | context = {'A': 'Broken', 'B': 'Broken', 'C': 'Broken'} 238 | status_updates = {'B': 'Working on fixing', 'C': 'now dead'} 239 | mocker.patch( 240 | 'commands.subte.updates.utils.get_suscriptors_by_line', 241 | return_value=[suscriptor(id_=1)] 242 | ) 243 | 244 | # Exercise 245 | notify_suscribers(bot, status_updates, context) 246 | 247 | # Validate 248 | assert bot.send_message.call_count == 3 249 | assert bot.send_message.call_args_list == [ 250 | call(chat_id=1, text=f'B | {SUBWAY_ICON}️ Working on fixing'), 251 | call(chat_id=1, text=f'C | {SUBWAY_ICON}️ now dead'), 252 | call(chat_id=1, text=SUBWAY_LINE_OK.format('A')) 253 | ] 254 | 255 | 256 | def test_send_to_corresponding_suscriptor(bot, mocker, suscriptor): 257 | """Test that updates are sent to the suscriptor of that line""" 258 | context = {'A': 'Broken', 'B': 'Broken'} 259 | status_updates = {'A': 'Working', 'B': 'Working'} 260 | mocker.patch( 261 | 'commands.subte.updates.utils.get_suscriptors_by_line', 262 | side_effect=[ 263 | (suscriptor(id_=1), (suscriptor(id_=2))), # suscribers to A line 264 | (suscriptor(id_=10), (suscriptor(id_=11))), # suscribers to B line 265 | ] 266 | ) 267 | 268 | # Exercise 269 | notify_suscribers(bot, status_updates, context) 270 | 271 | # Assert updates were sent to the correct suscriptors. 272 | assert bot.send_message.call_count == 4 273 | assert bot.send_message.call_args_list == [ 274 | call(chat_id=1, text='A | 🚆️ Working'), 275 | call(chat_id=2, text='A | 🚆️ Working'), 276 | call(chat_id=10, text='B | 🚆️ Working'), 277 | call(chat_id=11, text='B | 🚆️ Working') 278 | ] 279 | 280 | 281 | @pytest.mark.parametrize('context, status_update, send_msg_calls', [ 282 | ( 283 | # From normal to one incident 284 | {}, {'A': 'update'}, 285 | [ 286 | call(chat_id='@subtescaba', text='A | ⚠️ update'), 287 | call(chat_id=2, text='A | 🚆️ update') 288 | ] 289 | ), 290 | ( 291 | # The incident was solved 292 | {'A': 'broken'}, {}, 293 | [ 294 | call(chat_id='@subtescaba', text=SUBWAY_STATUS_OK), 295 | call(chat_id=2, text=SUBWAY_LINE_OK.format('A')) 296 | ] 297 | ), 298 | ( 299 | # From normal to two incidents 300 | {}, {'B': 'b_update', 'C': 'c_update'}, 301 | [ 302 | call(chat_id='@subtescaba', text='B | ⚠️ b_update\nC | ⚠️ c_update'), 303 | call(chat_id=2, text='B | 🚆️ b_update'), 304 | call(chat_id=2, text='C | 🚆️ c_update') 305 | ] 306 | ), 307 | ( 308 | # All incidents were solved 309 | {'A': 'broken', 'B': 'broken'}, {}, 310 | [ 311 | call(chat_id='@subtescaba', text='✅ Todos los subtes funcionan con normalidad'), 312 | call(chat_id=2, text=SUBWAY_LINE_OK.format('A')), 313 | call(chat_id=2, text=SUBWAY_LINE_OK.format('B')) 314 | ] 315 | ), 316 | ( 317 | # Incident description changed 318 | {'A': 'status'}, {'A': 'update_changed'}, 319 | [ 320 | call(chat_id='@subtescaba', text='A | ⚠️ update_changed'), 321 | call(chat_id=2, text='A | 🚆️ update_changed') 322 | ] 323 | ), 324 | ( 325 | # First line still broken, B Fixed 326 | {'A': 'broken', 'B': 'delayed'}, {'A': 'broken'}, 327 | [ 328 | call(chat_id='@subtescaba', text='A | ⚠️ broken'), 329 | call(chat_id=2, text='✅ La linea B funciona con normalidad') 330 | ] 331 | ), 332 | ( 333 | # First line still broken, B changed status 334 | {'A': 'broken', 'B': 'suspended'}, {'A': 'fixed', 'B': 'resumed'}, 335 | [ 336 | call(chat_id='@subtescaba', text='A | ⚠️ fixed\nB | ⚠️ resumed'), 337 | call(chat_id=2, text='A | 🚆️ fixed'), 338 | call(chat_id=2, text='B | 🚆️ resumed'), 339 | ] 340 | ), 341 | ( 342 | # First line still broken, B changed status 343 | {'A': 'broken', 'B': 'broken', 'C': 'broken', 'D': 'broken', 'E': 'broken', 'H': 'broken'}, 344 | {'A': 'fixed', 'B': 'fixed', 'C': 'fixed', 'D': 'fixed', 'E': 'fixed', 'H': 'fixed'}, 345 | [ 346 | call(chat_id='@subtescaba', 347 | text='A | ⚠️ fixed\nB | ⚠️ fixed\nC | ⚠️ fixed\nD | ⚠️ fixed\nE | ⚠️ fixed\nH | ⚠️ fixed'), 348 | call(chat_id=2, text='A | 🚆️ fixed'), 349 | call(chat_id=2, text='B | 🚆️ fixed'), 350 | call(chat_id=2, text='C | 🚆️ fixed'), 351 | call(chat_id=2, text='D | 🚆️ fixed'), 352 | call(chat_id=2, text='E | 🚆️ fixed'), 353 | call(chat_id=2, text='H | 🚆️ fixed'), 354 | ] 355 | ) 356 | ]) 357 | def test_subte_updates_cron(mocker, bot, job, suscriptor, context, status_update, send_msg_calls): 358 | mocker.patch('commands.subte.updates.alerts.check_update', return_value=status_update) 359 | mocker.patch('commands.subte.updates.utils.get_suscriptors_by_line', return_value=[suscriptor(id_=2)]) 360 | mocker.patch('commands.subte.updates.utils.random.choice', return_value='⚠') # Avoid random icon to ease testing 361 | 362 | job = job(context) 363 | subte_updates_cron(bot, job) 364 | assert bot.send_message.call_args_list == send_msg_calls 365 | --------------------------------------------------------------------------------