diff --git a/app/main.py b/app/main.py index 08b27769..4ea86eff 100644 --- a/app/main.py +++ b/app/main.py @@ -11,7 +11,6 @@ agenda, calendar, categories, dayview, email, event, invitation, profile, search, telegram, whatsapp ) -from app.telegram.bot import telegram_bot def create_tables(engine, psql_environment): @@ -51,8 +50,6 @@ def create_tables(engine, psql_environment): for router in routers_to_include: app.include_router(router) -telegram_bot.set_webhook() - # TODO: I add the quote day to the home page # until the relevant calendar view will be developed. diff --git a/app/routers/telegram.py b/app/routers/telegram.py index 5c5e9d7b..b6eba4bc 100644 --- a/app/routers/telegram.py +++ b/app/routers/telegram.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Body, Depends, Request +from fastapi import APIRouter, Body, Depends from app.database.database import get_db from app.database.models import User @@ -13,13 +13,6 @@ ) -@router.get("/") -async def telegram(request: Request, session=Depends(get_db)): - - # todo: Add templating - return "Start using PyLander telegram bot!" - - @router.post("/") async def bot_client(req: dict = Body(...), session=Depends(get_db)): chat = Chat(req) diff --git a/app/telegram/bot.py b/app/telegram/bot.py index 30aeaf6c..ef07ae2a 100644 --- a/app/telegram/bot.py +++ b/app/telegram/bot.py @@ -1,3 +1,5 @@ +import asyncio + from app import config from app.dependencies import get_settings from .models import Bot @@ -9,3 +11,7 @@ WEBHOOK_URL = settings.webhook_url telegram_bot = Bot(BOT_API, WEBHOOK_URL) + +loop = asyncio.get_event_loop() +asyncio.set_event_loop(loop) +asyncio.ensure_future(telegram_bot.set_webhook()) diff --git a/app/telegram/handlers.py b/app/telegram/handlers.py index c522418a..97e6c92c 100644 --- a/app/telegram/handlers.py +++ b/app/telegram/handlers.py @@ -1,10 +1,16 @@ import datetime +import asyncio +import dateparser + from .keyboards import ( - DATE_FORMAT, gen_inline_keyboard, get_this_week_buttons, show_events_kb) + DATE_FORMAT, field_kb, gen_inline_keyboard, + get_this_week_buttons, new_event_kb, show_events_kb) from .models import Chat from .bot import telegram_bot +from app.database.database import get_db from app.database.models import User +from app.routers.event import create_event class MessageHandler: @@ -14,6 +20,7 @@ def __init__(self, chat: Chat, user: User): self.handlers = {} self.handlers['/start'] = self.start_handler self.handlers['/show_events'] = self.show_events_handler + self.handlers['/new_event'] = self.new_event_handler self.handlers['Today'] = self.today_handler self.handlers['This week'] = self.this_week_handler @@ -23,7 +30,10 @@ def __init__(self, chat: Chat, user: User): self.handlers[button['text']] = self.chosen_day_handler async def process_callback(self): - if self.chat.message in self.handlers: + if self.chat.user_id in telegram_bot.MEMORY: + return await self.process_new_event( + telegram_bot.MEMORY[self.chat.user_id]) + elif self.chat.message in self.handlers: return await self.handlers[self.chat.message]() return await self.default_handler() @@ -34,7 +44,7 @@ async def default_handler(self): async def start_handler(self): answer = f'''Hello, {self.chat.first_name}! -Welcome to Pylander telegram client!''' +Welcome to PyLendar telegram client!''' await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) return answer @@ -52,17 +62,20 @@ async def today_handler(self): _.events for _ in self.user.events if _.events.start <= today <= _.events.end] - answer = f"{today.strftime('%B %d')}, {today.strftime('%A')} Events:\n" - if not events: - answer = "There're no events today." + return await self._process_no_events_today() + answer = f"{today.strftime('%A, %B %d')}:\n" + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=answer) for event in events: - answer += f''' -From {event.start.strftime('%d/%m %H:%M')} \ -to {event.end.strftime('%d/%m %H:%M')}: {event.title}.\n''' + await self._send_event(event) + return answer - await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) + async def _process_no_events_today(self): + answer = "There're no events today." + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=answer) return answer async def this_week_handler(self): @@ -76,26 +89,168 @@ async def this_week_handler(self): return answer async def chosen_day_handler(self): - # Convert chosen day (string) to datetime format chosen_date = datetime.datetime.strptime( self.chat.message, DATE_FORMAT) - events = [ _.events for _ in self.user.events if _.events.start <= chosen_date <= _.events.end] - answer = f"{chosen_date.strftime('%B %d')}, \ -{chosen_date.strftime('%A')} Events:\n" - if not events: - answer = f"There're no events on {chosen_date.strftime('%B %d')}." + return await self._process_no_events_on_date(chosen_date) + answer = f"{chosen_date.strftime('%A, %B %d')}:\n" + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=answer) for event in events: - answer += f''' -From {event.start.strftime('%d/%m %H:%M')} \ -to {event.end.strftime('%d/%m %H:%M')}: {event.title}.\n''' + await self._send_event(event) + return answer - await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer) + async def _process_no_events_on_date(self, date): + answer = f"There're no events on {date.strftime('%B %d')}." + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=answer) + return answer + + async def _send_event(self, event): + start = event.start.strftime("%d %b %Y %H:%M") + end = event.end.strftime("%d %b %Y %H:%M") + text = f'Title:\n{event.title}\n\n' + text += f'Content:\n{event.content}\n\n' + text += f'Location:\n{event.location}\n\n' + text += f'Starts on:\n{start}\n\n' + text += f'Ends on:\n{end}' + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=text) + await asyncio.sleep(1) + + async def process_new_event(self, memo_dict): + if self.chat.message == 'cancel': + return await self._cancel_new_event_processing() + elif self.chat.message == 'restart': + return await self._restart_new_event_processing() + elif 'title' not in memo_dict: + return await self._process_title(memo_dict) + elif 'content' not in memo_dict: + return await self._process_content(memo_dict) + elif 'location' not in memo_dict: + return await self._process_location(memo_dict) + elif 'start' not in memo_dict: + return await self._process_start_date(memo_dict) + elif 'end' not in memo_dict: + return await self._process_end_date(memo_dict) + elif self.chat.message == 'create': + return await self._submit_new_event(memo_dict) + + async def new_event_handler(self): + telegram_bot.MEMORY[self.chat.user_id] = {} + answer = 'Please, give your event a title.' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=field_kb) + return answer + + async def _cancel_new_event_processing(self): + del telegram_bot.MEMORY[self.chat.user_id] + answer = '🚫 The process was canceled.' + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=answer) + return answer + + async def _restart_new_event_processing(self): + answer = await self.new_event_handler() + return answer + + async def _process_title(self, memo_dict): + memo_dict['title'] = self.chat.message + answer = f'Title:\n{memo_dict["title"]}\n\n' + answer += 'Add a description of the event.' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=field_kb) + return answer + + async def _process_content(self, memo_dict): + memo_dict['content'] = self.chat.message + answer = f'Content:\n{memo_dict["content"]}\n\n' + answer += 'Where the event will be held?' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=field_kb) + return answer + + async def _process_location(self, memo_dict): + memo_dict['location'] = self.chat.message + answer = f'Location:\n{memo_dict["location"]}\n\n' + answer += 'When does it start?' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=field_kb) + return answer + + async def _process_start_date(self, memo_dict): + date = dateparser.parse(self.chat.message) + if date: + return await self._add_start_date(memo_dict, date) + return await self._process_bad_date_input() + + async def _add_start_date(self, memo_dict, date): + memo_dict['start'] = date + answer = f'Starts on:\n{date.strftime("%d %b %Y %H:%M")}\n\n' + answer += 'And when does it end?' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=field_kb) + return answer + + async def _process_bad_date_input(self): + answer = '❗️ Please, enter a valid date/time.' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=field_kb) + return answer + + async def _process_end_date(self, memo_dict): + date = dateparser.parse(self.chat.message) + if date: + return await self._add_end_date(memo_dict, date) + return await self._process_bad_date_input() + + async def _add_end_date(self, memo_dict, date): + memo_dict['end'] = date + start_time = memo_dict["start"].strftime("%d %b %Y %H:%M") + answer = f'Title:\n{memo_dict["title"]}\n\n' + answer += f'Content:\n{memo_dict["content"]}\n\n' + answer += f'Location:\n{memo_dict["location"]}\n\n' + answer += f'Starts on:\n{start_time}\n\n' + answer += f'Ends on:\n{date.strftime("%d %b %Y %H:%M")}' + await telegram_bot.send_message( + chat_id=self.chat.user_id, + text=answer, + reply_markup=new_event_kb) + return answer + + async def _submit_new_event(self, memo_dict): + answer = 'New event was successfully created 🎉' + await telegram_bot.send_message( + chat_id=self.chat.user_id, text=answer) + # Save to database + create_event( + db=next(get_db()), + title=memo_dict['title'], + start=memo_dict['start'], + end=memo_dict['end'], + content=memo_dict['content'], + owner_id=self.user.id, + location=memo_dict['location'], + ) + # Delete current session + del telegram_bot.MEMORY[self.chat.user_id] return answer @@ -103,7 +258,7 @@ async def reply_unknown_user(chat): answer = f''' Hello, {chat.first_name}! -To use PyLander Bot you have to register +To use PyLendar Bot you have to register your Telegram Id in your profile page. Your Id is {chat.user_id} diff --git a/app/telegram/keyboards.py b/app/telegram/keyboards.py index 0cbdd66f..b832a5e5 100644 --- a/app/telegram/keyboards.py +++ b/app/telegram/keyboards.py @@ -10,6 +10,20 @@ ] ] +new_event_buttons = [ + [ + {'text': 'Create ✅', 'callback_data': 'create'}, + {'text': 'Cancel 🚫', 'callback_data': 'cancel'} + ] +] + +field_buttons = [ + [ + {'text': 'Restart 🚀', 'callback_data': 'restart'}, + {'text': 'Cancel 🚫', 'callback_data': 'cancel'} + ] +] + DATE_FORMAT = '%d %b %Y' @@ -45,3 +59,5 @@ def gen_inline_keyboard(buttons: List[List[Any]]) -> Dict[str, Any]: show_events_kb = gen_inline_keyboard(show_events_buttons) +new_event_kb = gen_inline_keyboard(new_event_buttons) +field_kb = gen_inline_keyboard(field_buttons) diff --git a/app/telegram/models.py b/app/telegram/models.py index b34b1a6e..467fa1be 100644 --- a/app/telegram/models.py +++ b/app/telegram/models.py @@ -1,7 +1,6 @@ from typing import Any, Dict, Optional from httpx import AsyncClient -import requests class Chat: @@ -27,6 +26,8 @@ def _get_first_name(self, data: Dict) -> str: class Bot: + MEMORY = {} + def __init__(self, bot_api: str, webhook_url: str): self.base = self._set_base_url(bot_api) self.webhook_setter_url = self._set_webhook_setter_url(webhook_url) @@ -37,12 +38,14 @@ def _set_base_url(self, bot_api: str) -> str: def _set_webhook_setter_url(self, webhook_url: str) -> str: return f'{self.base}setWebhook?url={webhook_url}/telegram/' - def set_webhook(self): - return requests.get(self.webhook_setter_url) + async def set_webhook(self): + async with AsyncClient() as ac: + return await ac.get(self.webhook_setter_url) - def drop_webhook(self): - data = {'drop_pending_updates': True} - return requests.get(url=f'{self.base}deleteWebhook', data=data) + async def drop_webhook(self): + async with AsyncClient() as ac: + data = {'drop_pending_updates': True} + return await ac.post(url=f'{self.base}deleteWebhook', data=data) async def send_message( self, chat_id: str, diff --git a/requirements.txt b/requirements.txt index 9038c58f..ff8f6bfb 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/tests/test_a_telegram_asyncio.py b/tests/test_a_telegram_asyncio.py index 37d832ac..23637355 100644 --- a/tests/test_a_telegram_asyncio.py +++ b/tests/test_a_telegram_asyncio.py @@ -1,4 +1,4 @@ -from datetime import timedelta +from datetime import datetime, timedelta from fastapi import status import pytest @@ -107,16 +107,14 @@ async def test_bot_model(): assert bot.webhook_setter_url == bot._set_webhook_setter_url( "https://google.com") - set_request = bot.set_webhook() - assert set_request.status_code == status.HTTP_404_NOT_FOUND + set_request = await bot.set_webhook() assert set_request.json() == { 'ok': False, 'error_code': 404, 'description': 'Not Found' } - drop_request = bot.drop_webhook() - assert drop_request.status_code == status.HTTP_404_NOT_FOUND + drop_request = await bot.drop_webhook() assert drop_request.json() == { 'ok': False, 'error_code': 404, @@ -132,6 +130,29 @@ async def test_bot_model(): } +class TestBotClient: + + @staticmethod + @pytest.mark.asyncio + async def test_user_not_registered(telegram_client): + response = await telegram_client.post( + '/telegram/', json=gen_message('/start')) + assert response.status_code == status.HTTP_200_OK + assert b'Hello, Moshe!' in response.content + assert b'To use PyLendar Bot you have to register' \ + in response.content + + @staticmethod + @pytest.mark.asyncio + async def test_user_registered(telegram_client, session): + session.add(get_test_placeholder_user()) + session.commit() + response = await telegram_client.post( + '/telegram/', json=gen_message('/start')) + assert response.status_code == status.HTTP_200_OK + assert b'Welcome to PyLendar telegram client!' in response.content + + class TestHandlers: TEST_USER = get_test_placeholder_user() @@ -142,7 +163,7 @@ async def test_start_handlers(self): assert '/start' in message.handlers assert await message.process_callback() == '''Hello, Moshe! -Welcome to Pylander telegram client!''' +Welcome to PyLendar telegram client!''' @pytest.mark.asyncio async def test_default_handlers(self): @@ -173,12 +194,8 @@ async def test_no_today_events_handler(self): async def test_today_handler(self, fake_user_events): chat = Chat(gen_callback('Today')) message = MessageHandler(chat, fake_user_events) - assert await message.process_callback() == f'''\ -{today_date.strftime('%B %d')}, {today_date.strftime('%A')} Events: - -From {today_date.strftime('%d/%m %H:%M')} \ -to {(today_date + timedelta(days=2)).strftime('%d/%m %H:%M')}: \ -Cool today event.\n''' + answer = f"{today_date.strftime('%A, %B %d')}:\n" + assert await message.process_callback() == answer @pytest.mark.asyncio async def test_this_week_handler(self): @@ -191,8 +208,8 @@ async def test_no_chosen_day_handler(self): chat = Chat(gen_callback('10 Feb 2021')) message = MessageHandler(chat, self.TEST_USER) message.handlers['10 Feb 2021'] = message.chosen_day_handler - assert await message.process_callback() == \ - "There're no events on February 10." + answer = "There're no events on February 10." + assert await message.process_callback() == answer @pytest.mark.asyncio async def test_chosen_day_handler(self, fake_user_events): @@ -201,16 +218,97 @@ async def test_chosen_day_handler(self, fake_user_events): chat = Chat(gen_callback(button)) message = MessageHandler(chat, fake_user_events) message.handlers[button] = message.chosen_day_handler - assert await message.chosen_day_handler() == f'''\ -{chosen_date.strftime('%B %d')}, {chosen_date.strftime('%A')} Events: + answer = f"{chosen_date.strftime('%A, %B %d')}:\n" + assert await message.chosen_day_handler() == answer + + @pytest.mark.asyncio + async def test_new_event_handler(self): + chat = Chat(gen_message('/new_event')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Please, give your event a title.' + assert await message.process_callback() == answer + + @pytest.mark.asyncio + async def test_process_new_event(self): + chat = Chat(gen_message('New Title')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Title:\nNew Title\n\n' + answer += 'Add a description of the event.' + assert await message.process_callback() == answer + + chat = Chat(gen_message('New Content')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Content:\nNew Content\n\n' + answer += 'Where the event will be held?' + assert await message.process_callback() == answer + + chat = Chat(gen_message('Universe')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Location:\nUniverse\n\n' + answer += 'When does it start?' + assert await message.process_callback() == answer + + chat = Chat(gen_message('Not valid start datetime input')) + message = MessageHandler(chat, self.TEST_USER) + answer = '❗️ Please, enter a valid date/time.' + assert await message.process_callback() == answer + + chat = Chat(gen_message('today')) + message = MessageHandler(chat, self.TEST_USER) + today = datetime.today() + answer = f'Starts on:\n{today.strftime("%d %b %Y %H:%M")}\n\n' + answer += 'And when does it end?' + assert await message.process_callback() == answer + + chat = Chat(gen_message('Not valid end datetime input')) + message = MessageHandler(chat, self.TEST_USER) + answer = '❗️ Please, enter a valid date/time.' + assert await message.process_callback() == answer -From {today_date.strftime('%d/%m %H:%M')} \ -to {(today_date + timedelta(days=2)).strftime('%d/%m %H:%M')}: \ -Cool today event. + chat = Chat(gen_message('tomorrow')) + message = MessageHandler(chat, self.TEST_USER) + tomorrow = today + timedelta(days=1) + answer = 'Title:\nNew Title\n\n' + answer += 'Content:\nNew Content\n\n' + answer += 'Location:\nUniverse\n\n' + answer += f'Starts on:\n{today.strftime("%d %b %Y %H:%M")}\n\n' + answer += f'Ends on:\n{tomorrow.strftime("%d %b %Y %H:%M")}' + assert await message.process_callback() == answer + + chat = Chat(gen_message('create')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'New event was successfully created 🎉' + assert await message.process_callback() == answer + + @pytest.mark.asyncio + async def test_process_new_event_cancel(self): + chat = Chat(gen_message('/new_event')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Please, give your event a title.' + assert await message.process_callback() == answer -From {(chosen_date + timedelta(days=-1)).strftime('%d/%m %H:%M')} \ -to {(chosen_date + timedelta(days=1)).strftime('%d/%m %H:%M')}: \ -Cool (somewhen in two days) event.\n''' + chat = Chat(gen_message('cancel')) + message = MessageHandler(chat, self.TEST_USER) + answer = '🚫 The process was canceled.' + assert await message.process_callback() == answer + + @pytest.mark.asyncio + async def test_process_new_event_restart(self): + chat = Chat(gen_message('/new_event')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Please, give your event a title.' + assert await message.process_callback() == answer + + chat = Chat(gen_message('New Title')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Title:\nNew Title\n\n' + answer += 'Add a description of the event.' + assert await message.process_callback() == answer + + chat = Chat(gen_message('restart')) + message = MessageHandler(chat, self.TEST_USER) + answer = 'Please, give your event a title.' + assert await message.process_callback() == answer @pytest.mark.asyncio @@ -220,7 +318,7 @@ async def test_reply_unknown_user(): assert answer == ''' Hello, Moshe! -To use PyLander Bot you have to register +To use PyLendar Bot you have to register your Telegram Id in your profile page. Your Id is 666666 @@ -228,33 +326,3 @@ async def test_reply_unknown_user(): https://calendar.pythonic.guru/profile/ ''' - - -class TestBotClient: - - @staticmethod - @pytest.mark.asyncio - async def test_user_not_registered(telegram_client): - response = await telegram_client.post( - '/telegram/', json=gen_message('/start')) - assert response.status_code == status.HTTP_200_OK - assert b'Hello, Moshe!' in response.content - assert b'To use PyLander Bot you have to register' \ - in response.content - - @staticmethod - @pytest.mark.asyncio - async def test_user_registered(telegram_client, session): - session.add(get_test_placeholder_user()) - session.commit() - response = await telegram_client.post( - '/telegram/', json=gen_message('/start')) - assert response.status_code == status.HTTP_200_OK - assert b'Welcome to Pylander telegram client!' in response.content - - @staticmethod - @pytest.mark.asyncio - async def test_telegram_router(telegram_client): - response = await telegram_client.get('/telegram') - assert response.status_code == status.HTTP_200_OK - assert b"Start using PyLander telegram bot!" in response.content