diff --git a/Dockerfile b/Dockerfile
index 1f72b91..9e0e5d9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -10,8 +10,3 @@ RUN pip install --no-cache-dir -r prod.txt
# Copy the rest of the application files
COPY . .
-
-# Apply migrations
-# RUN alembic -c app/alembic.ini upgrade head
-
-CMD ["python", "-m", "app"]
diff --git a/app/bot.py b/app/bot.py
index 918fac6..892b576 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -4,6 +4,7 @@ from typing import Optional
from aiogram import Bot, Dispatcher
from aiogram.enums import ParseMode
+from aiogram.fsm.storage.redis import RedisStorage
from app.callbacks import profile
from app.config import Config
@@ -12,22 +13,23 @@ from app.middlewares.throttling import ThrottlingMiddleware
async def main() -> None:
- dp = Dispatcher()
-
bot_token: Optional[str] = Config.BOT_TOKEN
- if bot_token is not None:
- bot = Bot(bot_token, parse_mode=ParseMode.HTML)
- dp.message.middleware(ThrottlingMiddleware(0.5))
- # type: ignore
- dp.include_routers(
- start_command.router,
- profile_command.router,
- profile.router,
- help_command.router,
- )
-
- await bot.delete_webhook(drop_pending_updates=True)
- await dp.start_polling(bot)
- else:
+ if bot_token is None:
exit("BOT_TOKEN is not set")
+
+ storage = RedisStorage.from_url(Config.REDIS_URL)
+ dp = Dispatcher(storage=storage)
+ bot = Bot(bot_token, parse_mode=ParseMode.HTML)
+
+ dp.message.middleware(ThrottlingMiddleware(0.5))
+ # type: ignore
+ dp.include_routers(
+ start_command.router,
+ profile_command.router,
+ profile.router, # type: ignore
+ help_command.router,
+ )
+
+ await bot.delete_webhook(drop_pending_updates=True)
+ await dp.start_polling(bot)
diff --git a/app/callbacks/profile.py b/app/callbacks/profile.py
index 8a7d4d1..e574033 100644
--- a/app/callbacks/profile.py
+++ b/app/callbacks/profile.py
@@ -33,23 +33,31 @@ async def profile_change_callback(
column = callback.data.replace("profile_change_", "")
if column == "username":
- message = await callback.message.answer(messages.EDIT_USERNAME)
+ message = await callback.message.answer(
+ f"{messages.EDIT_USERNAME}\n{messages.CANCEL_CHANGE}",
+ )
elif column == "age":
- message = await callback.message.answer(messages.INPUT_AGE)
+ message = await callback.message.answer(
+ f"{messages.INPUT_AGE}\n{messages.CANCEL_CHANGE}",
+ )
elif column == "bio":
- message = await callback.message.answer(messages.EDIT_BIO)
+ message = await callback.message.answer(
+ f"{messages.EDIT_BIO}\n{messages.CANCEL_CHANGE}",
+ )
elif column == "sex":
message = await callback.message.answer(
- messages.INPUT_SEX,
+ f"{messages.INPUT_SEX}\n{messages.CANCEL_CHANGE}",
reply_markup=profile(["Male", "Female"]),
)
elif column == "location":
- message = await callback.message.answer(messages.INPUT_LOCATION)
+ message = await callback.message.answer(
+ f"{messages.INPUT_LOCATION}\n{messages.CANCEL_CHANGE}",
+ )
await state.update_data(
column=column,
message_id=callback.message.message_id,
- input_message=message,
+ input_message_id=message.message_id,
)
await state.set_state(UserAltering.value)
@@ -61,6 +69,23 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
column = (await state.get_data()).get("column")
value = message.text.strip()
+ if value == "/cancel":
+ await message.answer(
+ messages.CHANGE_CANCELED,
+ reply_markup=ReplyKeyboardRemove(),
+ )
+
+ await state.update_data(successfully=True)
+ await message.delete()
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
+ await state.clear()
+
+ return
+
if column == "username":
try:
validated_value = User().validate_username(
@@ -69,10 +94,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
@@ -82,10 +113,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
validated_age = User().validate_age(key="age", value=value)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
@@ -93,16 +130,26 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
elif column == "bio":
if value == "/skip":
await state.update_data(value=None, successfully=True)
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
else:
try:
validated_bio = User().validate_bio(key="bio", value=value)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
@@ -114,10 +161,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
validated_sex = User().validate_sex(key="sex", value=value)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
@@ -127,12 +180,18 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
if len(location) != 2:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(
messages.VALIDATION_ERROR_MESSAGE,
)
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
@@ -145,10 +204,16 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
@@ -159,18 +224,27 @@ async def profile_change_entered(message: Message, state: FSMContext) -> None:
)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
- await state.update_data(value=[validated_country, validated_city])
+ await state.update_data(
+ value=[validated_country, validated_city],
+ successfully=True,
+ )
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
state_data = await state.get_data()
diff --git a/app/config.py b/app/config.py
index 034d344..831a73a 100644
--- a/app/config.py
+++ b/app/config.py
@@ -13,3 +13,7 @@ class Config:
"SQLALCHEMY_DATABASE_URI",
"sqlite:///database.db",
)
+ REDIS_URL = os.getenv(
+ "REDIS_URL",
+ "redis://localhost:6379",
+ )
diff --git a/app/handlers/start_command.py b/app/handlers/start_command.py
index e58a903..9248be2 100644
--- a/app/handlers/start_command.py
+++ b/app/handlers/start_command.py
@@ -52,14 +52,14 @@ async def username_handler(message: Message, state: FSMContext) -> None:
)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(previous_message_id=error_message.message_id)
return
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
await state.update_data(username=validated_username)
await state.set_state(RegistrationForm.age)
@@ -84,14 +84,14 @@ async def age_handler(message: Message, state: FSMContext) -> None:
validated_age = User().validate_age(key="age", value=age)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(previous_message_id=error_message.message_id)
return
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
await state.update_data(age=validated_age)
await state.set_state(RegistrationForm.sex)
@@ -116,14 +116,14 @@ async def sex_handler(message: Message, state: FSMContext) -> None:
validated_sex = User().validate_sex(key="sex", value=sex)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(previous_message_id=error_message.message_id)
return
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
await state.update_data(sex=validated_sex)
await state.set_state(RegistrationForm.bio)
@@ -146,7 +146,7 @@ async def bio_handler(message: Message, state: FSMContext) -> None:
await state.update_data(bio=None)
await state.set_state(RegistrationForm.location)
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
await message.answer(messages.INPUT_BIO_SKIPPED)
await message.answer(messages.INPUT_LOCATION)
@@ -155,14 +155,20 @@ async def bio_handler(message: Message, state: FSMContext) -> None:
validated_bio = User().validate_bio(key="bio", value=bio)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(
+ state,
+ message.chat.id,
+ message.bot,
+ )
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(
+ previous_message_id=error_message.message_id,
+ )
return
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
await state.update_data(bio=validated_bio)
await state.set_state(RegistrationForm.location)
@@ -182,10 +188,10 @@ async def location_handler(message: Message, state: FSMContext) -> None:
if len(location) != 2:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
error_message = await message.answer(messages.VALIDATION_ERROR_MESSAGE)
- await state.update_data(previous_message=error_message)
+ await state.update_data(previous_message_id=error_message.message_id)
return
@@ -198,10 +204,10 @@ async def location_handler(message: Message, state: FSMContext) -> None:
)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(previous_message_id=error_message.message_id)
return
@@ -212,14 +218,14 @@ async def location_handler(message: Message, state: FSMContext) -> None:
)
except AssertionError as e:
await message.delete()
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
error_message = await message.answer(str(e))
- await state.update_data(previous_message=error_message)
+ await state.update_data(previous_message_id=error_message.message_id)
return
- await delete_message_from_state(state)
+ await delete_message_from_state(state, message.chat.id, message.bot)
await state.update_data(location=[validated_country, validated_city])
data = await state.get_data()
@@ -236,7 +242,9 @@ async def location_handler(message: Message, state: FSMContext) -> None:
data["country"] = data["location"][0]
data["city"] = data["location"][1]
del data["location"]
- del data["previous_message"]
+
+ if "previous_message_id" in data:
+ del data["previous_message_id"]
session.add(User(**data))
session.commit()
diff --git a/app/messages.py b/app/messages.py
index db287d0..72d2d0e 100644
--- a/app/messages.py
+++ b/app/messages.py
@@ -15,6 +15,7 @@ INPUT_BIO_SKIPPED = "Sure. You can always fill it later."
INPUT_LOCATION = "Enter your location in this format:\nFormat: country, city\nExample: Russia, Moscow"
INPUT_CALLBACK = "All right, your {key} is set to: {value}"
VALIDATION_ERROR_MESSAGE = "Invalid input. Please try again."
+CANCEL_CHANGE = "Enter /cancel to cancel change."
PROFILE = (
"Your profile:\n\n"
@@ -22,10 +23,11 @@ PROFILE = (
"\tAge: {age}\n"
"\tSex: {sex}\n"
"\tCountry: {country}\n"
- "\tCity: {city}"
+ "\tCity: {city}\n"
"\tBio: {bio}\n"
)
NOT_SET = "Not set"
EDIT_USERNAME = "Enter your username:\nAllowed characters: a-z, A-Z, 0-9, _\nLength: 5-20 characters"
EDIT_BIO = "Enter your bio (enter /skip if you want to set it to None):\nMaximum length: 100 characters"
PROFILE_UPDATED = "✅ Profile updated"
+CHANGE_CANCELED = "❌ Change canceled"
diff --git a/app/migrations/versions/2ed063a74f39_added_user_model.py b/app/migrations/versions/2ed063a74f39_added_user_model.py
new file mode 100644
index 0000000..556adaa
--- /dev/null
+++ b/app/migrations/versions/2ed063a74f39_added_user_model.py
@@ -0,0 +1,46 @@
+"""Added user model
+
+Revision ID: 2ed063a74f39
+Revises:
+Create Date: 2024-03-21 21:40:05.789793
+
+"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '2ed063a74f39'
+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.BigInteger(), nullable=False),
+ sa.Column('username', sa.String(length=32), 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'),
+ )
+ op.create_index(
+ op.f('ix_users_telegram_id'), 'users', ['telegram_id'], unique=False
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index(op.f('ix_users_telegram_id'), table_name='users')
+ op.drop_table('users')
+ # ### end Alembic commands ###
diff --git a/app/migrations/versions/50fa8edaaf94_added_user_model.py b/app/migrations/versions/50fa8edaaf94_added_user_model.py
deleted file mode 100644
index 152235f..0000000
--- a/app/migrations/versions/50fa8edaaf94_added_user_model.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""Added user model
-
-Revision ID: 50fa8edaaf94
-Revises:
-Create Date: 2024-03-21 18:31:15.864426
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = '50fa8edaaf94'
-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.BigInteger(), 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')
- )
- op.create_index(op.f('ix_users_telegram_id'), 'users', ['telegram_id'], unique=False)
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_index(op.f('ix_users_telegram_id'), table_name='users')
- op.drop_table('users')
- # ### end Alembic commands ###
diff --git a/app/models/user.py b/app/models/user.py
index 582ab35..d9963b1 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -18,7 +18,7 @@ class User(Base):
__tablename__ = "users"
telegram_id = sa.Column(sa.BigInteger, primary_key=True, index=True)
- username = sa.Column(sa.String(20), nullable=False, unique=True)
+ username = sa.Column(sa.String(32), 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)
@@ -29,7 +29,7 @@ class User(Base):
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) <= 32, "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
@@ -42,7 +42,7 @@ class User(Base):
@validates("age")
def validate_age(self, key, value):
- assert str(value).isnumeric(), "Invalid input. Please try again."
+ assert str(value).isdigit(), "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."
diff --git a/app/utils/states.py b/app/utils/states.py
index 6aee704..0b3b016 100644
--- a/app/utils/states.py
+++ b/app/utils/states.py
@@ -1,12 +1,13 @@
__all__ = ("RegistrationForm",)
+from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest
-from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.context import FSMContext
+from aiogram.fsm.state import State, StatesGroup
class RegistrationForm(StatesGroup):
- previous_message = State()
+ previous_message_id = State()
username = State()
age = State()
bio = State()
@@ -15,34 +16,50 @@ class RegistrationForm(StatesGroup):
class UserAltering(StatesGroup):
- successfully = State()
- message_id = State()
- input_message = State()
- previous_message = State()
column = State()
value = State()
+ message_id = State()
+ input_message_id = State()
+ previous_message_id = State()
+ successfully = State()
-async def delete_message_from_state(state: FSMContext) -> None:
+async def delete_message_from_state(
+ state: FSMContext,
+ chat_id: int,
+ bot: Bot | None,
+) -> None:
+ if bot is None:
+ return
+
data = await state.get_data()
- if "previous_message" in data and data["previous_message"] is not None:
+ if (
+ "previous_message_id" in data
+ and data["previous_message_id"] is not None
+ ):
try:
- await data["previous_message"].delete()
+ await bot.delete_message(
+ message_id=data["previous_message_id"],
+ chat_id=chat_id,
+ )
except TelegramBadRequest:
pass
- await state.update_data(previous_message=None)
+ await state.update_data(previous_message_id=None)
if (
- "input_message" in data
- and data["input_message"] is not None
+ "input_message_id" in data
+ and data["input_message_id"] is not None
and "successfully" in data
and data["successfully"]
):
try:
- await data["input_message"].delete()
+ await bot.delete_message(
+ message_id=data["input_message_id"],
+ chat_id=chat_id,
+ )
except TelegramBadRequest:
pass
- await state.update_data(info_message=None)
+ await state.update_data(input_message_id=None)
diff --git a/docker-compose.yml b/docker-compose.yml
index 3204a16..0f3abf2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,22 +1,62 @@
name: travel_agent_bot
-version: '3'
+version: "3"
services:
- app:
- build: .
- container_name: bot
- depends_on:
- - postgres
-
postgres:
image: postgres:latest
- container_name: db
+ container_name: postgres
+ healthcheck:
+ test: pg_isready -U postgres -h localhost
+ interval: 5s
+ timeout: 5s
+ retries: 10
environment:
POSTGRES_DB: postgres
POSTGRES_USER: postgres
POSTGRES_PASSWORD: wTAb5KoZ4dBtscg
ports:
- "5432:5432"
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+
+ redis:
+ image: redis:latest
+ container_name: redis
+ healthcheck:
+ test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
+ ports:
+ - "6379:6379"
+ volumes:
+ - redis_data:/data
+
+ app:
+ build: .
+ container_name: bot
+ depends_on:
+ postgres:
+ condition: service_healthy
+ redis:
+ condition: service_healthy
+ environment:
+ BOT_TOKEN: ${BOT_TOKEN}
+ REDIS_URL: redis://redis:6379/
+ SQLALCHEMY_DATABASE_URI: postgresql://postgres:wTAb5KoZ4dBtscg@postgres:5432/postgres
+ entrypoint: ["bash", "-c"]
+ command: ["alembic -c app/alembic.ini upgrade head && python -m app"]
+
+ pgadmin:
+ image: dpage/pgadmin4
+ container_name: pgadmin
+ environment:
+ PGADMIN_DEFAULT_EMAIL: admin@mail.com
+ PGADMIN_DEFAULT_PASSWORD: admin
+ ports:
+ - "5050:80"
+ restart: always
+ volumes:
+ - pgadmin_data:/var/lib/pgadmin
volumes:
- travel_agent_bot_data:
+ postgres_data:
+ redis_data:
+ pgadmin_data:
diff --git a/requirements/prod.txt b/requirements/prod.txt
index 575c28b..efcd7e9 100644
--- a/requirements/prod.txt
+++ b/requirements/prod.txt
@@ -4,4 +4,5 @@ cachetools==5.3.3
geopy==2.4.1
psycopg2-binary==2.9.9
python-dotenv==1.0.1
+redis==5.0.3
sqlalchemy==2.0.28