Skip to content

Commit bcf470b

Browse files
authored
Add telegram client (#111)
Add telegram client
1 parent 1ee995b commit bcf470b

19 files changed

+700
-22
lines changed

app/config.py.example

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
11
import os
22

33
from fastapi_mail import ConnectionConfig
4-
# flake8: noqa
4+
from pydantic import BaseSettings
5+
6+
7+
class Settings(BaseSettings):
8+
app_name: str = "PyLander"
9+
bot_api: str = "BOT_API"
10+
webhook_url: str = "WEBHOOK_URL"
11+
12+
class Config:
13+
env_file = ".env"
14+
515

616
# general
717
DOMAIN = 'Our-Domain'

app/database/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class User(Base):
3232
full_name = Column(String)
3333
description = Column(String, default="Happy new user!")
3434
avatar = Column(String, default="profile.png")
35+
telegram_id = Column(String, unique=True)
3536
is_active = Column(Boolean, default=False)
3637

3738
events = relationship("UserEvent", back_populates="participants")

app/dependencies.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from functools import lru_cache
12
import os
23

34
from fastapi.templating import Jinja2Templates
@@ -11,3 +12,8 @@
1112
TEMPLATES_PATH = os.path.join(APP_PATH, "templates")
1213

1314
templates = Jinja2Templates(directory=TEMPLATES_PATH)
15+
16+
17+
@lru_cache()
18+
def get_settings():
19+
return config.Settings()

app/main.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66
from app.database.database import engine
77
from app.dependencies import (
88
MEDIA_PATH, STATIC_PATH, templates)
9-
from app.routers import (agenda, dayview, email, event, invitation, profile,
10-
search)
9+
from app.routers import (
10+
agenda, dayview, email, event, invitation, profile, search, telegram)
11+
from app.telegram.bot import telegram_bot
1112

1213

1314
def create_tables(engine, psql_environment):
@@ -29,11 +30,14 @@ def create_tables(engine, psql_environment):
2930
app.include_router(profile.router)
3031
app.include_router(event.router)
3132
app.include_router(agenda.router)
33+
app.include_router(telegram.router)
3234
app.include_router(dayview.router)
3335
app.include_router(email.router)
3436
app.include_router(invitation.router)
3537
app.include_router(search.router)
3638

39+
telegram_bot.set_webhook()
40+
3741

3842
@app.get("/")
3943
async def home(request: Request):

app/media/fake_user.png

-3.47 KB
Binary file not shown.

app/routers/profile.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def get_placeholder_user():
2626
2727
password='1a2s3d4f5g6',
2828
full_name='My Name',
29+
telegram_id=''
2930
)
3031

3132

@@ -63,8 +64,7 @@ async def update_user_fullname(
6364
session.commit()
6465

6566
url = router.url_path_for("profile")
66-
response = RedirectResponse(url=url, status_code=HTTP_302_FOUND)
67-
return response
67+
return RedirectResponse(url=url, status_code=HTTP_302_FOUND)
6868

6969

7070
@router.post("/update_user_email")
@@ -110,14 +110,27 @@ async def upload_user_photo(
110110
# Save to database
111111
user.avatar = await process_image(pic, user)
112112
session.commit()
113-
114113
finally:
115-
session.close()
116-
117114
url = router.url_path_for("profile")
118115
return RedirectResponse(url=url, status_code=HTTP_302_FOUND)
119116

120117

118+
@router.post("/update_telegram_id")
119+
async def update_telegram_id(
120+
request: Request, session=Depends(get_db)):
121+
122+
user = session.query(User).filter_by(id=1).first()
123+
data = await request.form()
124+
new_telegram_id = data['telegram_id']
125+
126+
# Update database
127+
user.telegram_id = new_telegram_id
128+
session.commit()
129+
130+
url = router.url_path_for("profile")
131+
return RedirectResponse(url=url, status_code=HTTP_302_FOUND)
132+
133+
121134
async def process_image(image, user):
122135
img = Image.open(io.BytesIO(image))
123136
width, height = img.size

app/routers/telegram.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from fastapi import APIRouter, Body, Depends, Request
2+
3+
from app.database.database import get_db
4+
from app.database.models import User
5+
from app.telegram.handlers import MessageHandler, reply_unknown_user
6+
from app.telegram.models import Chat
7+
8+
9+
router = APIRouter(
10+
prefix="/telegram",
11+
tags=["telegram"],
12+
responses={404: {"description": "Not found"}},
13+
)
14+
15+
16+
@router.get("/")
17+
async def telegram(request: Request, session=Depends(get_db)):
18+
19+
# todo: Add templating
20+
return "Start using PyLander telegram bot!"
21+
22+
23+
@router.post("/")
24+
async def bot_client(req: dict = Body(...), session=Depends(get_db)):
25+
chat = Chat(req)
26+
27+
# Check if current chatter is registered to use the bot
28+
user = session.query(User).filter_by(telegram_id=chat.user_id).first()
29+
if user is None:
30+
return await reply_unknown_user(chat)
31+
32+
message = MessageHandler(chat, user)
33+
return await message.process_callback()

app/telegram/__init__.py

Whitespace-only changes.

app/telegram/bot.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from app import config
2+
from app.dependencies import get_settings
3+
from .models import Bot
4+
5+
6+
settings: config.Settings = get_settings()
7+
8+
BOT_API = settings.bot_api
9+
WEBHOOK_URL = settings.webhook_url
10+
11+
telegram_bot = Bot(BOT_API, WEBHOOK_URL)

app/telegram/handlers.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import datetime
2+
3+
from .keyboards import (
4+
DATE_FORMAT, gen_inline_keyboard, get_this_week_buttons, show_events_kb)
5+
from .models import Chat
6+
from .bot import telegram_bot
7+
from app.database.models import User
8+
9+
10+
class MessageHandler:
11+
def __init__(self, chat: Chat, user: User):
12+
self.chat = chat
13+
self.user = user
14+
self.handlers = {}
15+
self.handlers['/start'] = self.start_handler
16+
self.handlers['/show_events'] = self.show_events_handler
17+
self.handlers['Today'] = self.today_handler
18+
self.handlers['This week'] = self.this_week_handler
19+
20+
# Add next 6 days to handlers dict
21+
for row in get_this_week_buttons():
22+
for button in row:
23+
self.handlers[button['text']] = self.chosen_day_handler
24+
25+
async def process_callback(self):
26+
if self.chat.message in self.handlers:
27+
return await self.handlers[self.chat.message]()
28+
return await self.default_handler()
29+
30+
async def default_handler(self):
31+
answer = "Unknown command."
32+
await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer)
33+
return answer
34+
35+
async def start_handler(self):
36+
answer = f'''Hello, {self.chat.first_name}!
37+
Welcome to Pylander telegram client!'''
38+
await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer)
39+
return answer
40+
41+
async def show_events_handler(self):
42+
answer = 'Choose events day.'
43+
await telegram_bot.send_message(
44+
chat_id=self.chat.user_id,
45+
text=answer,
46+
reply_markup=show_events_kb)
47+
return answer
48+
49+
async def today_handler(self):
50+
today = datetime.datetime.today()
51+
events = [
52+
_.events for _ in self.user.events
53+
if _.events.start <= today <= _.events.end]
54+
55+
answer = f"{today.strftime('%B %d')}, {today.strftime('%A')} Events:\n"
56+
57+
if not events:
58+
answer = "There're no events today."
59+
60+
for event in events:
61+
answer += f'''
62+
From {event.start.strftime('%d/%m %H:%M')} \
63+
to {event.end.strftime('%d/%m %H:%M')}: {event.title}.\n'''
64+
65+
await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer)
66+
return answer
67+
68+
async def this_week_handler(self):
69+
answer = 'Choose a day.'
70+
this_week_kb = gen_inline_keyboard(get_this_week_buttons())
71+
72+
await telegram_bot.send_message(
73+
chat_id=self.chat.user_id,
74+
text=answer,
75+
reply_markup=this_week_kb)
76+
return answer
77+
78+
async def chosen_day_handler(self):
79+
# Convert chosen day (string) to datetime format
80+
chosen_date = datetime.datetime.strptime(
81+
self.chat.message, DATE_FORMAT)
82+
83+
events = [
84+
_.events for _ in self.user.events
85+
if _.events.start <= chosen_date <= _.events.end]
86+
87+
answer = f"{chosen_date.strftime('%B %d')}, \
88+
{chosen_date.strftime('%A')} Events:\n"
89+
90+
if not events:
91+
answer = f"There're no events on {chosen_date.strftime('%B %d')}."
92+
93+
for event in events:
94+
answer += f'''
95+
From {event.start.strftime('%d/%m %H:%M')} \
96+
to {event.end.strftime('%d/%m %H:%M')}: {event.title}.\n'''
97+
98+
await telegram_bot.send_message(chat_id=self.chat.user_id, text=answer)
99+
return answer
100+
101+
102+
async def reply_unknown_user(chat):
103+
answer = f'''
104+
Hello, {chat.first_name}!
105+
106+
To use PyLander Bot you have to register
107+
your Telegram Id in your profile page.
108+
109+
Your Id is {chat.user_id}
110+
Keep it secret!
111+
112+
https://calendar.pythonic.guru/profile/
113+
'''
114+
await telegram_bot.send_message(chat_id=chat.user_id, text=answer)
115+
return answer

app/telegram/keyboards.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import datetime
2+
import json
3+
from typing import Any, Dict, List
4+
5+
6+
show_events_buttons = [
7+
[
8+
{'text': 'Today', 'callback_data': 'Today'},
9+
{'text': 'This week', 'callback_data': 'This week'}
10+
]
11+
]
12+
13+
DATE_FORMAT = '%d %b %Y'
14+
15+
16+
def get_this_week_buttons() -> List[List[Any]]:
17+
today = datetime.datetime.today()
18+
buttons = []
19+
for day in range(1, 7):
20+
day = today + datetime.timedelta(days=day)
21+
buttons.append(day.strftime(DATE_FORMAT))
22+
23+
return [
24+
[
25+
{'text': buttons[0],
26+
'callback_data': buttons[0]},
27+
{'text': buttons[1],
28+
'callback_data': buttons[1]},
29+
{'text': buttons[2],
30+
'callback_data': buttons[2]}
31+
],
32+
[
33+
{'text': buttons[3],
34+
'callback_data': buttons[3]},
35+
{'text': buttons[4],
36+
'callback_data': buttons[4]},
37+
{'text': buttons[5],
38+
'callback_data': buttons[5]}
39+
]
40+
]
41+
42+
43+
def gen_inline_keyboard(buttons: List[List[Any]]) -> Dict[str, Any]:
44+
return {'reply_markup': json.dumps({'inline_keyboard': buttons})}
45+
46+
47+
show_events_kb = gen_inline_keyboard(show_events_buttons)

app/telegram/models.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from typing import Any, Dict, Optional
2+
3+
from httpx import AsyncClient
4+
import requests
5+
6+
7+
class Chat:
8+
def __init__(self, data: Dict):
9+
self.message = self._get_message_content(data)
10+
self.user_id = self._get_user_id(data)
11+
self.first_name = self._get_first_name(data)
12+
13+
def _get_message_content(self, data: Dict) -> str:
14+
if 'callback_query' in data:
15+
return data['callback_query']['data']
16+
return data['message']['text']
17+
18+
def _get_user_id(self, data: Dict) -> str:
19+
if 'callback_query' in data:
20+
return data['callback_query']['from']['id']
21+
return data['message']['from']['id']
22+
23+
def _get_first_name(self, data: Dict) -> str:
24+
if 'callback_query' in data:
25+
return data['callback_query']['from']['first_name']
26+
return data['message']['from']['first_name']
27+
28+
29+
class Bot:
30+
def __init__(self, bot_api: str, webhook_url: str):
31+
self.base = self._set_base_url(bot_api)
32+
self.webhook_setter_url = self._set_webhook_setter_url(webhook_url)
33+
34+
def _set_base_url(self, bot_api: str) -> str:
35+
return f'https://api.telegram.org/bot{bot_api}/'
36+
37+
def _set_webhook_setter_url(self, webhook_url: str) -> str:
38+
return f'{self.base}setWebhook?url={webhook_url}/telegram/'
39+
40+
def set_webhook(self):
41+
return requests.get(self.webhook_setter_url)
42+
43+
def drop_webhook(self):
44+
data = {'drop_pending_updates': True}
45+
return requests.get(url=f'{self.base}deleteWebhook', data=data)
46+
47+
async def send_message(
48+
self, chat_id: str,
49+
text: str,
50+
reply_markup: Optional[Dict[str, Any]] = None):
51+
async with AsyncClient(base_url=self.base) as ac:
52+
message = {
53+
'chat_id': chat_id,
54+
'text': text}
55+
if reply_markup:
56+
message.update(reply_markup)
57+
return await ac.post('sendMessage', data=message)

0 commit comments

Comments
 (0)