feat: Reorganized project, added user registration, throttling middleware, help command, profile command

This commit is contained in:
ITQ
2024-03-20 20:53:43 +03:00
parent 7086a1cf52
commit 6d755490d6
21 changed files with 603 additions and 44 deletions
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import sessionmaker
from app.config import Config
engine: Engine = create_engine(Config.SQLALCHEMY_DATABASE_URI)
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = Session()
+15 -20
View File
@@ -2,34 +2,29 @@ __all__ = ("main",)
from typing import Optional from typing import Optional
from aiogram import Bot, Dispatcher, types from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode from aiogram.enums import ParseMode
from aiogram.filters import CommandStart
from aiogram.types import Message
from aiogram.utils.markdown import hbold
from app.config import Config from app.config import Config
from app.handlers import help_command, profile_command, start_command
from app.middlewares.throttling import ThrottlingMiddleware
dp = Dispatcher()
@dp.message(CommandStart())
async def command_start_handler(message: Message) -> None:
await message.answer(f"Hello, {hbold(message.from_user.full_name)}!")
@dp.message()
async def echo_handler(message: types.Message) -> None:
try:
await message.send_copy(chat_id=message.chat.id)
except TypeError:
await message.answer("Nice try!")
async def main() -> None: async def main() -> None:
dp = Dispatcher()
bot_token: Optional[str] = Config.BOT_TOKEN bot_token: Optional[str] = Config.BOT_TOKEN
if bot_token is not None: if bot_token is not None:
bot = Bot(bot_token, parse_mode=ParseMode.HTML) bot = Bot(bot_token, parse_mode=ParseMode.HTML)
dp.message.middleware(ThrottlingMiddleware(0.5))
dp.include_routers(
start_command.router,
profile_command.router,
help_command.router,
)
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot) await dp.start_polling(bot)
else: else:
exit("BOT_TOKEN is not set") exit("BOT_TOKEN is not set")
+5 -2
View File
@@ -8,5 +8,8 @@ load_dotenv()
class Config: class Config:
BOT_TOKEN = os.getenv("BOT_TOKEN") BOT_TOKEN = os.getenv("BOT_TOKEN", "")
SQLALCHEMY_DATABASE_URI = os.getenv("SQLALCHEMY_DATABASE_URI") SQLALCHEMY_DATABASE_URI = os.getenv(
"SQLALCHEMY_DATABASE_URI",
"sqlite:///database.db",
)
+27
View File
@@ -0,0 +1,27 @@
__all__ = ("Unregistered", "Registered", "RegisteredCallback")
from aiogram.filters import Filter
from aiogram.types import CallbackQuery, Message
from app.models.user import User
class Unregistered(Filter):
async def __call__(self, message: Message) -> bool:
if message.from_user is None:
return False
return not User.user_by_telegram_id_exist(message.from_user.id)
class Registered(Filter):
async def __call__(self, message: Message) -> bool:
if message.from_user is None:
return False
return User.user_by_telegram_id_exist(message.from_user.id)
class RegisteredCallback(Filter):
async def __call__(self, callback: CallbackQuery) -> bool:
return User.user_by_telegram_id_exist(callback.from_user.id)
+16
View File
@@ -0,0 +1,16 @@
__all__ = ()
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from app import messages
from app.filters.user_filter import Registered
router = Router(name="help_command")
@router.message(Command("help"), Registered())
async def command_help_handler(message: Message) -> None:
await message.answer(messages.HELP_MESSAGE)
+31
View File
@@ -0,0 +1,31 @@
# type: ignore
__all__ = ()
from aiogram import Router
from aiogram.filters import Command
from aiogram.types import Message
from app import messages
from app.filters.user_filter import Registered
from app.keyboards.profile import get
from app.models.user import User
router = Router(name="profile_command")
@router.message(Command("profile"), Registered())
async def command_profile_handler(message: Message) -> None:
user = User().get_user_by_telegram_id(message.from_user.id)
await message.answer(
messages.PROFILE.format(
username=user.username,
age=user.age,
bio=user.bio if user.bio else messages.NOT_SET,
sex=user.sex.capitalize(),
country=user.country,
city=user.city,
),
reply_markup=get(),
)
+179
View File
@@ -0,0 +1,179 @@
# type: ignore
__all__ = ()
from aiogram import F, Router
from aiogram.filters import CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, ReplyKeyboardRemove
from app import messages, session
from app.keyboards.builders import profile
from app.models.user import User
from app.utils.states import RegistrationForm
router = Router(name="start_command")
@router.message(CommandStart())
async def command_start_handler(message: Message, state: FSMContext) -> None:
if (
User.get_user_by_telegram_id(
telegram_id=message.from_user.id,
)
is not None
):
await message.answer(
messages.WELCOME_AGAIN_MESSAGE.format(
name=message.from_user.full_name,
),
)
else:
await message.answer(
messages.WELCOME_MESSAGE.format(
name=message.from_user.full_name,
),
)
await state.set_state(RegistrationForm.username)
await message.answer(messages.INPUT_USERNAME)
@router.message(RegistrationForm.username, F.text)
async def username_handler(message: Message, state: FSMContext) -> None:
username = message.text.strip()
try:
validated_username = User().validate_username(
key="username",
value=username,
)
except AssertionError as e:
await message.answer(str(e))
return
await state.update_data(username=validated_username)
await state.set_state(RegistrationForm.age)
await message.answer(
messages.INPUT_CALLBACK.format(
key="username",
value=validated_username,
),
)
await message.answer(messages.INPUT_AGE)
@router.message(RegistrationForm.age, F.text)
async def age_handler(message: Message, state: FSMContext) -> None:
age = message.text.strip()
try:
validated_age = User().validate_age(key="age", value=age)
except AssertionError as e:
await message.answer(str(e))
return
await state.update_data(age=validated_age)
await state.set_state(RegistrationForm.sex)
await message.answer(
messages.INPUT_CALLBACK.format(key="age", value=validated_age),
)
await message.answer(
messages.INPUT_SEX,
reply_markup=profile(["Male", "Female"]),
)
@router.message(RegistrationForm.sex, F.text)
async def sex_handler(message: Message, state: FSMContext) -> None:
sex = message.text.strip().lower()
if sex not in ["male", "female"]:
await message.answer(messages.VALIDATION_ERROR_MESSAGE)
return
await state.update_data(sex=sex)
await state.set_state(RegistrationForm.bio)
await message.answer(
messages.INPUT_CALLBACK.format(key="sex", value=sex),
reply_markup=ReplyKeyboardRemove(),
)
await message.answer(messages.INPUT_BIO)
@router.message(RegistrationForm.bio, F.text)
async def bio_handler(message: Message, state: FSMContext) -> None:
bio = message.text.strip()
if bio == "/skip":
await state.update_data(bio=None)
await state.set_state(RegistrationForm.location)
await message.answer(messages.INPUT_BIO_SKIPPED)
await message.answer(messages.INPUT_LOCATION)
else:
try:
validated_bio = User().validate_bio(key="bio", value=bio)
except AssertionError as e:
await message.answer(str(e))
return
await state.update_data(bio=validated_bio)
await state.set_state(RegistrationForm.location)
await message.answer(
messages.INPUT_CALLBACK.format(key="bio", value=validated_bio),
)
await message.answer(messages.INPUT_LOCATION)
@router.message(RegistrationForm.location, F.text)
async def location_handler(message: Message, state: FSMContext) -> None:
location = message.text.strip().split(", ")
if len(location) != 2:
await message.answer(messages.VALIDATION_ERROR_MESSAGE)
return
country, city = location
try:
validated_country = User().validate_country(
key="country",
value=country,
)
except AssertionError as e:
await message.answer(str(e))
return
try:
validated_city = User().validate_city(
city=city,
country=validated_country,
)
except AssertionError as e:
await message.answer(str(e))
return
await state.update_data(location=[validated_country, validated_city])
data = await state.get_data()
await state.clear()
await message.answer(
messages.INPUT_CALLBACK.format(
key="location",
value=", ".join([validated_country, validated_city]),
),
)
data["telegram_id"] = message.from_user.id
data["country"] = data["location"][0]
data["city"] = data["location"][1]
del data["location"]
session.add(User(**data))
session.commit()
await message.answer(messages.REGISTERED_MESSAGE)
+13
View File
@@ -0,0 +1,13 @@
__all__ = ("profile",)
from aiogram.utils.keyboard import ReplyKeyboardBuilder
def profile(text: str | list):
builder = ReplyKeyboardBuilder()
if isinstance(text, str):
text = [text]
[builder.button(text=txt) for txt in text]
return builder.as_markup(resize_keyboard=True)
+37
View File
@@ -0,0 +1,37 @@
__all__ = ("get",)
from aiogram import types
from aiogram.utils.keyboard import InlineKeyboardBuilder
def get():
builder = InlineKeyboardBuilder()
builder.row(
types.InlineKeyboardButton(
text="👤 Change username",
callback_data="profile_change_username",
),
types.InlineKeyboardButton(
text="🔢 Change age",
callback_data="profile_change_age",
),
)
builder.row(
types.InlineKeyboardButton(
text="️ Change bio",
callback_data="profile_change_bio",
),
types.InlineKeyboardButton(
text="📝 Change sex",
callback_data="profile_change_sex",
),
)
builder.row(
types.InlineKeyboardButton(
text="🗺️ Change location",
callback_data="profile_change_location",
),
)
return builder.as_markup()
+28
View File
@@ -0,0 +1,28 @@
# flake8: noqa
WELCOME_MESSAGE = "Hello, <b>{name}</b>! Welcome to the ✈️ Travel Agent bot! Let's start our journey by filling out some information about you."
WELCOME_AGAIN_MESSAGE = "Hello, <b>{name}</b>! Welcome back to the ✈️ Travel Agent bot! If you get lost, you can always call the /help command for assistance."
HELP_MESSAGE = "Help message text."
REGISTERED_MESSAGE = "You have successfully registered. Welcome to the ✈️ Travel Agent bot! \nYou can view and edit your profile using the /profile command."
INPUT_USERNAME = "Enter your username (this will be used to interact with other users):\n<i>Allowed characters: a-z, A-Z, 0-9, _</i>\n<i>Length: 5-20 characters</i>"
INPUT_AGE = "Enter your age:\n<i>Range: 13-120</i>"
INPUT_SEX = "Enter your sex:\n<i>Options: Male or Female</i>"
INPUT_BIO = "Enter your bio (enter /skip if you want to skip this step):\n<i>Maximum length: 100 characters</i>"
INPUT_BIO_SKIPPED = "Sure. You can always fill it later."
INPUT_LOCATION = "Enter your location in this format:\n<i>Format: country, city</i>\n<i>Example: Russia, Moscow</i>"
INPUT_CALLBACK = "All right, your <b>{key}</b> is set to: <b>{value}</b>"
VALIDATION_ERROR_MESSAGE = "Invalid input. Please try again."
PROFILE = (
"<b>Your profile:</b>\n\n"
"\tUsername: <b>{username}</b>\n"
"\tAge: <b>{age}</b>\n"
"\tSex: <b>{sex}</b>\n"
"\tBio: <b>{bio}</b>\n"
"\tCountry: <b>{country}</b>\n"
"\tCity: <b>{city}</b>"
)
NOT_SET = "<i>Not set</i>"
+26
View File
@@ -0,0 +1,26 @@
__all__ = ("ThrottlingMiddleware",)
from typing import Any, Awaitable, Callable, Dict
from aiogram import BaseMiddleware
from aiogram.types import Message
from cachetools import TTLCache # type: ignore
class ThrottlingMiddleware(BaseMiddleware):
def __init__(self, time_limit: int | float = 2) -> None:
self.limit = TTLCache(maxsize=10_000, ttl=time_limit)
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Message, # type: ignore
data: Dict[str, Any],
) -> Any | None:
if event.chat.id in self.limit:
return None
self.limit[event.chat.id] = None
return await handler(event, data)
-1
View File
@@ -1 +0,0 @@
Generic single-database configuration.
+2 -1
View File
@@ -4,11 +4,12 @@ from logging.config import fileConfig
import os import os
from alembic import context from alembic import context
from app.models import Base
from dotenv import load_dotenv from dotenv import load_dotenv
from sqlalchemy import engine_from_config from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from app.models.user import Base
load_dotenv() load_dotenv()
@@ -0,0 +1,42 @@
"""Added User model
Revision ID: 5896f08fbd61
Revises:
Create Date: 2024-03-19 23:25:50.458639
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5896f08fbd61'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
'users',
sa.Column('telegram_id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=20), nullable=False),
sa.Column('age', sa.SmallInteger(), nullable=False),
sa.Column('bio', sa.String(length=100), nullable=True),
sa.Column('sex', sa.String(length=6), nullable=True),
sa.Column('country', sa.Text(), nullable=False),
sa.Column('city', sa.Text(), nullable=False),
sa.PrimaryKeyConstraint('telegram_id'),
sa.UniqueConstraint('username'),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('users')
# ### end Alembic commands ###
-19
View File
@@ -1,19 +0,0 @@
__all__ = ("User",)
from typing import Any
from sqlalchemy import Column, Integer, SmallInteger, String
from sqlalchemy.ext.declarative import declarative_base
Base: Any = declarative_base()
class User(Base):
__tablename__ = "users"
telegram_id: Column[int] = Column(Integer, primary_key=True)
age: Column[int] = Column(SmallInteger, nullable=False)
bio: Column[str] = Column(String(100), nullable=True)
country: Column[str] = Column(String(100), nullable=False)
city: Column[str] = Column(String(100), nullable=False)
+89
View File
@@ -0,0 +1,89 @@
__all__ = ("User",)
import re
from typing import Any
import sqlalchemy as sa
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import validates
from app import session
from app.utils import geo
Base: Any = declarative_base()
class User(Base):
__tablename__ = "users"
telegram_id = sa.Column(sa.Integer, primary_key=True)
username = sa.Column(sa.String(20), nullable=False, unique=True)
age = sa.Column(sa.SmallInteger, nullable=False)
bio = sa.Column(sa.String(100), nullable=True)
sex = sa.Column(sa.String(6), nullable=True)
country = sa.Column(sa.Text, nullable=False)
city = sa.Column(sa.Text, nullable=False)
@validates("username")
def validate_username(self, key, value):
regex_pattern = re.compile(r"^[a-zA-Z0-9_]{5,20}$")
assert len(value) <= 20, "Username must be 20 characters or fewer."
assert len(value) >= 5, "Username must be at least 5 characters."
assert (
re.match(regex_pattern, value) is not None
), "a-z, A-Z, 0-9, _ only allowed in username."
return value
@validates("age")
def validate_age(self, key, value):
assert str(value).isnumeric(), "Invalid input. Please try again."
value = int(value)
assert value >= 13, "You must be at least 13 years old."
assert value <= 120, "You must be less than 120 years old."
return value
@validates("bio")
def validate_bio(self, key, value):
if value is not None:
assert len(value) <= 100, "Bio must be 100 characters or fewer."
return value
@validates("country")
def validate_country(self, key, value):
verdict, normalized_value = geo.validate_country(
value,
)
assert verdict, "There is no such country."
return normalized_value
def validate_city(self, city, country):
verdict, normalized_value = geo.validate_city(
city,
country,
)
assert verdict, "There is no such city in selected country."
return normalized_value
@classmethod
def get_user_by_telegram_id(cls, telegram_id):
return (
session.query(cls).filter(cls.telegram_id == telegram_id).first()
)
@classmethod
def user_by_telegram_id_exist(cls, telegram_id):
return (
cls.get_user_by_telegram_id(
telegram_id=telegram_id,
)
is not None
)
View File
+66
View File
@@ -0,0 +1,66 @@
# type: ignore
__all__ = ("validate_country", "validate_city")
from geopy.exc import GeocoderTimedOut
from geopy.geocoders import Nominatim
def validate_country(country: str):
geolocator = Nominatim(user_agent="travel_agent_bot")
for _ in range(3):
try:
geocode = geolocator.geocode(
country,
featuretype="country",
)
break
except GeocoderTimedOut:
continue
else:
return False, None
if not geocode:
return False, None
is_loc_country = geocode.raw.get(
"type", None,
) == "administrative"
if is_loc_country:
normalized_country = geocode.raw.get("name", "Invalid country")
return True, normalized_country
return False, None
def validate_city(city: str, country: str):
geolocator = Nominatim(user_agent="travel_agent_bot")
location_name = f"{country}, {city}"
valid_list = ["city", "town", "administrative"]
for _ in range(3):
try:
geocode = geolocator.geocode(
location_name,
featuretype="city",
)
break
except GeocoderTimedOut:
continue
else:
return False, None
if not geocode:
return False, None
check_in_valid = geocode.raw.get(
"type", None,
) in valid_list
if geocode and check_in_valid:
normalized_country = geocode.raw.get("name", "Invalid city")
return True, normalized_country
return False, None
+15
View File
@@ -0,0 +1,15 @@
__all__ = ("RegistrationForm",)
from aiogram.fsm.state import State, StatesGroup
class RegistrationForm(StatesGroup):
username = State()
age = State()
bio = State()
sex = State()
location = State()
class UserAltering(StatesGroup):
new_value = State()
-1
View File
@@ -13,7 +13,6 @@ flake8-import-order
flake8-print flake8-print
flake8-quotes flake8-quotes
flake8-return flake8-return
flake8-type-ignore
flake8-use-pathlib flake8-use-pathlib
flake8_implicit_str_concat flake8_implicit_str_concat
pep8-naming pep8-naming
+2
View File
@@ -1,5 +1,7 @@
aiogram==3.4.1 aiogram==3.4.1
alembic==1.13.1 alembic==1.13.1
cachetools==5.3.3
geopy==2.4.1
psycopg2-binary==2.9.9 psycopg2-binary==2.9.9
python-dotenv==1.0.1 python-dotenv==1.0.1
sqlalchemy==2.0.28 sqlalchemy==2.0.28