From 7d4e2002f64d9d085483e0b642bc9f70bcb50def Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sat, 6 Jul 2024 22:20:07 +0300 Subject: [PATCH 01/97] test(unit): added unit tests for 3 functions + script that runs tests --- poetry.lock | 105 +++++++++++++++++- pyproject.toml | 3 + scripts/run_tests.sh | 3 + tests/__init__.py | 0 tests/conftest.py | 51 +++++++++ tests/mocked_bot.py | 99 +++++++++++++++++ tests/test_handlers/__init__.py | 0 tests/test_non_handlers/__init__.py | 0 .../test_get_message_link.py | 38 +++++++ tests/test_non_handlers/test_make_job_id.py | 9 ++ .../test_non_handlers/test_reminder_period.py | 58 ++++++++++ tests/utils.py | 36 ++++++ 12 files changed, 401 insertions(+), 1 deletion(-) create mode 100755 scripts/run_tests.sh create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/mocked_bot.py create mode 100644 tests/test_handlers/__init__.py create mode 100644 tests/test_non_handlers/__init__.py create mode 100644 tests/test_non_handlers/test_get_message_link.py create mode 100644 tests/test_non_handlers/test_make_job_id.py create mode 100644 tests/test_non_handlers/test_reminder_period.py create mode 100644 tests/utils.py diff --git a/poetry.lock b/poetry.lock index b8cef8b..10e46f5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -484,6 +484,17 @@ files = [ {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"}, ] +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + [[package]] name = "isort" version = "5.13.2" @@ -537,6 +548,35 @@ files = [ {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mongomock" +version = "4.1.2" +description = "Fake pymongo stub for testing simple MongoDB-dependent code" +optional = false +python-versions = "*" +files = [ + {file = "mongomock-4.1.2-py2.py3-none-any.whl", hash = "sha256:08a24938a05c80c69b6b8b19a09888d38d8c6e7328547f94d46cadb7f47209f2"}, + {file = "mongomock-4.1.2.tar.gz", hash = "sha256:f06cd62afb8ae3ef63ba31349abd220a657ef0dd4f0243a29587c5213f931b7d"}, +] + +[package.dependencies] +packaging = "*" +sentinels = "*" + +[[package]] +name = "mongomock-motor" +version = "0.0.30" +description = "Library for mocking AsyncIOMotorClient built on top of mongomock." +optional = false +python-versions = ">=3.6" +files = [ + {file = "mongomock_motor-0.0.30-py3-none-any.whl", hash = "sha256:5eb41488ce5825ebf1a1ddc90c6ff2c870b2d8506bd1096790408f53068f0ca6"}, + {file = "mongomock_motor-0.0.30.tar.gz", hash = "sha256:7977413755f70c7ca306e407f5e0d1e49dfa428ce6f2551b097f4db59f8c285b"}, +] + +[package.dependencies] +mongomock = ">=3.23.0,<5.0.0" + [[package]] name = "motor" version = "3.4.0" @@ -755,6 +795,21 @@ docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx- test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] type = ["mypy (>=1.8)"] +[[package]] +name = "pluggy" +version = "1.5.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + [[package]] name = "pydantic" version = "2.7.4" @@ -992,6 +1047,44 @@ snappy = ["python-snappy"] test = ["pytest (>=7)"] zstd = ["zstandard"] +[[package]] +name = "pytest" +version = "8.2.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-8.2.2-py3-none-any.whl", hash = "sha256:c434598117762e2bd304e526244f67bf66bbd7b5d6cf22138be51ff661980343"}, + {file = "pytest-8.2.2.tar.gz", hash = "sha256:de4bb8104e201939ccdc688b27a89a7be2079b22e2bd2b07f806b6ba71117977"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=1.5,<2.0" + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "python-dotenv" version = "1.0.1" @@ -1017,6 +1110,16 @@ files = [ {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] +[[package]] +name = "sentinels" +version = "1.0.0" +description = "Various objects to denote special meanings in python" +optional = false +python-versions = "*" +files = [ + {file = "sentinels-1.0.0.tar.gz", hash = "sha256:7be0704d7fe1925e397e92d18669ace2f619c92b5d4eb21a89f31e026f9ff4b1"}, +] + [[package]] name = "six" version = "1.16.0" @@ -1195,4 +1298,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "e8ae960678a3a927a13f4f5f3de26b609ae82fbe989f0232cee2f9163eac69bf" +content-hash = "e657cf5258a6156d5d22a0e7de7f43f094ea5352b66267b906bbf712bfa5fff2" diff --git a/pyproject.toml b/pyproject.toml index 05fce26..0259807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ black = "^24.4.2" mypy = "^1.10.0" pylint = "^3.1.0" babel = "^2.15.0" +pytest = "^8.2.2" +pytest-asyncio = "^0.23.7" +mongomock-motor = "^0.0.30" [tool.poetry.scripts] bot = "bot.main:main" diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh new file mode 100755 index 0000000..a726ca2 --- /dev/null +++ b/scripts/run_tests.sh @@ -0,0 +1,3 @@ +#! /bin/bash +echo "Running the tests..." +poetry run pytest diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..dbd2091 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,51 @@ +import pytest_asyncio +from mongomock_motor import AsyncMongoMockClient +from beanie import init_beanie +from aiogram import Dispatcher + +from bot.state import ChatState, UserPM, create_state, get_user, save_state + +from tests.utils import TEST_CHAT, TEST_USER, TEST_USER_2 + + +# @pytest.fixture() +# def bot(): +# return MockedBot() + + +@pytest_asyncio.fixture() +async def dispatcher(): + dp = Dispatcher() + await dp.emit_startup() + try: + yield dp + finally: + await dp.emit_shutdown() + + +@pytest_asyncio.fixture(autouse=True) +async def mock_db(): + """ + Automatic fixture. Create new mongodb instance for each test. + """ + client = AsyncMongoMockClient() + await init_beanie(document_models=[ChatState, UserPM], database=client.get_database(name="db")) + + +@pytest_asyncio.fixture() +async def chat_state_2_users(mock_db) -> ChatState: + """ + Create new ChatState with 2 test users. + Does not register users (they do not /start bot in PM). + Returns: + ChatState: The chat state instance created. + """ + chat_state = await create_state(TEST_CHAT.id, topic_id=1) + await get_user(chat_state, TEST_USER.username) + await get_user(chat_state, TEST_USER_2.username) + await save_state(chat_state) + yield chat_state + +# @pytest.fixture(scope="session") +# def event_loop(): +# return asyncio.get_event_loop() diff --git a/tests/mocked_bot.py b/tests/mocked_bot.py new file mode 100644 index 0000000..3547aa8 --- /dev/null +++ b/tests/mocked_bot.py @@ -0,0 +1,99 @@ +from collections import deque +from typing import TYPE_CHECKING, Any, AsyncGenerator, Deque, Dict, Optional, Type + +from aiogram import Bot +from aiogram.client.session.base import BaseSession +from aiogram.methods import TelegramMethod +from aiogram.methods.base import Response, TelegramType +from aiogram.types import UNSET_PARSE_MODE, ResponseParameters, User + + +class MockedSession(BaseSession): + def __init__(self): + super(MockedSession, self).__init__() + self.responses: Deque[Response[TelegramType]] = deque() + self.requests: Deque[TelegramMethod[TelegramType]] = deque() + self.closed = True + + def add_result(self, response: Response[TelegramType]) -> Response[TelegramType]: + self.responses.append(response) + return response + + def get_request(self) -> TelegramMethod[TelegramType]: + return self.requests.pop() + + async def close(self): + self.closed = True + + async def make_request( + self, + bot: Bot, + method: TelegramMethod[TelegramType], + timeout: Optional[int] = UNSET_PARSE_MODE, + ) -> TelegramType: + self.closed = False + self.requests.append(method) + response: Response[TelegramType] = self.responses.pop() + self.check_response( + bot=bot, + method=method, + status_code=response.error_code, + content=response.model_dump_json(), + ) + return response.result # type: ignore + + async def stream_content( + self, + url: str, + headers: Optional[Dict[str, Any]] = None, + timeout: int = 30, + chunk_size: int = 65536, + raise_for_status: bool = True, + ) -> AsyncGenerator[bytes, None]: # pragma: no cover + yield b"" + + +class MockedBot(Bot): + if TYPE_CHECKING: + session: MockedSession + + def __init__(self, **kwargs): + super(MockedBot, self).__init__( + kwargs.pop("token", "42:TEST"), session=MockedSession(), **kwargs + ) + self._me = User( + id=self.id, + is_bot=True, + first_name="FirstName", + last_name="LastName", + username="tbot", + language_code="uk-UA", + ) + + def add_result_for( + self, + method: Type[TelegramMethod[TelegramType]], + ok: bool, + result: TelegramType = None, + description: Optional[str] = None, + error_code: int = 200, + migrate_to_chat_id: Optional[int] = None, + retry_after: Optional[int] = None, + ) -> Response[TelegramType]: + response = Response[method.__returning__]( # type: ignore + ok=ok, + result=result, + description=description, + error_code=error_code, + parameters=ResponseParameters( + migrate_to_chat_id=migrate_to_chat_id, + retry_after=retry_after, + ), + ) + self.session.add_result(response) + return response + + def get_request(self) -> TelegramMethod[TelegramType]: + return self.session.get_request() + + diff --git a/tests/test_handlers/__init__.py b/tests/test_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_non_handlers/__init__.py b/tests/test_non_handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_non_handlers/test_get_message_link.py b/tests/test_non_handlers/test_get_message_link.py new file mode 100644 index 0000000..afd7f39 --- /dev/null +++ b/tests/test_non_handlers/test_get_message_link.py @@ -0,0 +1,38 @@ +from bot.reminder import get_message_link +from tests.utils import * + + +def test_get_message_link(): + """ + Tests the accuracy of the generation of tg links. + """ + msg_id = 1 + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="supergroup") == f"https://t.me/c/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id) + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=1, + chat_type="supergroup") == f"https://t.me/c/%s/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id, 1) + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="private") is None + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="group") is None + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=None, + chat_type="channel") is None + + assert get_message_link(chat_id=TEST_CHAT.id, + message_id=msg_id, + thread_id=1, + chat_type="channel") is None diff --git a/tests/test_non_handlers/test_make_job_id.py b/tests/test_non_handlers/test_make_job_id.py new file mode 100644 index 0000000..3841940 --- /dev/null +++ b/tests/test_non_handlers/test_make_job_id.py @@ -0,0 +1,9 @@ +from bot.reminder import make_job_id + + +def test_make_job_id(): + """ + Tests the accuracy of the generation of scheduler id. + """ + assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=3) == f"1_reminder_for_2_3" + assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=None) == f"1_reminder_for_2" diff --git a/tests/test_non_handlers/test_reminder_period.py b/tests/test_non_handlers/test_reminder_period.py new file mode 100644 index 0000000..a9b76de --- /dev/null +++ b/tests/test_non_handlers/test_reminder_period.py @@ -0,0 +1,58 @@ +import pytest +from unittest.mock import AsyncMock + +from bot.reminder import update_reminders +from tests.utils import * + + +@pytest.mark.asyncio +async def test_update_reminders(chat_state_2_users: ChatState): + """ + Tests the update reminders function with various users and chat_state configurations. + The main criteria - number of calls to scheduler. + """ + bot = AsyncMock() + username = TEST_USER.username + username_2 = TEST_USER_2.username + scheduler = AsyncMock() + send_message = AsyncMock() + + # Call without username should not change anything + await update_reminders(bot=bot, username=None, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + # Call without registered users should not change anything + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + await register_2_users() + + # Calls without set meeting time and set reminder periods should not change anything + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + await set_meeting_time(chat_state_2_users) + + # Calls with set meeting time but without set reminder periods should not change anything + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + scheduler.assert_not_called() + + chat_state_2_users.users[TEST_USER.username].reminder_period = 1 + await save_state(chat_state_2_users) + + # 1 user set reminder period - 1 call to scheduler + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + assert scheduler.add_job.call_count == 1 + scheduler.reset_mock(return_value=True, side_effect=True) + + await set_reminder_period_2_users(chat_state_2_users) + + # 2 user set reminder period - 2 calls to scheduler + await update_reminders(bot=bot, username=username, scheduler=scheduler, send_message=send_message) + await update_reminders(bot=bot, username=username_2, scheduler=scheduler, send_message=send_message) + assert scheduler.add_job.call_count == 2 diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..2a139e2 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,36 @@ +from datetime import datetime + +from aiogram.types import User, Chat + +from bot.constants import sample_time +from bot.state import create_user_pm, ChatState, save_state + +TEST_USER = User(id=11111111111111, is_bot=False, first_name="Test", + last_name="User", username="test", language_code="en-US", is_premium=False) + +TEST_USER_2 = User(id=2222222222222, is_bot=False, first_name="Test_2", + last_name="User_2", username="test_2", language_code="en-US", is_premium=False) + +TEST_CHAT = Chat(id=33333333333333, type="supergroup", is_forum=True, title="Test chat", description="Test chat") + +# TEST_USER's chat +TEST_CHAT_PRIVATE = Chat(id=44444444444444444, type="private", title="Test chat", description="Test chat") + +# TEST_USER_2's chat +TEST_CHAT_PRIVATE_2 = Chat(id=5555555555555, type="private", title="Test chat", description="Test chat") + + +async def register_2_users(): + await create_user_pm(TEST_USER.username, TEST_CHAT_PRIVATE.id) + await create_user_pm(TEST_USER_2.username, TEST_CHAT_PRIVATE_2.id) + + +async def set_reminder_period_2_users(chat_state: ChatState): + chat_state.users[TEST_USER.username].reminder_period = 1 + chat_state.users[TEST_USER_2.username].reminder_period = 1 + await save_state(chat_state) + + +async def set_meeting_time(chat_state: ChatState): + chat_state.meeting_time = datetime.fromisoformat(sample_time) + await save_state(chat_state) \ No newline at end of file From 903c922e30ea70464ab28f8166424f644e028443 Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sat, 6 Jul 2024 22:45:43 +0300 Subject: [PATCH 02/97] test(tool): added coverage tool --- .coverage | Bin 0 -> 53248 bytes poetry.lock | 66 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 .coverage diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..fae51ff4d1180b001fe0e39ad9fad224dab0fb05 GIT binary patch literal 53248 zcmeI5Ux?hs9ml0z-DQRX??zj5;iFqN=qRW8(sjQWT{`-wJ)R zpi@H2HYipH(px1<%KUeaSJ-zHv+!euJzII2&6%%M_Lbi!SvS9Ql|(g6ez009sH zfloJq-U(B$Pfn^Yd_Q&?9Ug~HlZVM~YAUMA)IAj8@(D5|EyQfI&G zH3<2cuD2>0he{+B$H7)5w<;S4sj+w6Hk-6%-`=6-%#e!~O-glr_gt56H2$pBFF7mC z@Pk=P7(pKJV7X8~Fs>@(mHa|o-MLiG&T!;5-kVHlI(ACd0zV?MUQ0i$W8MbGAGSvFQ$oNPuKMN#Dv;QdW0C4#CL4S2(hQ%1#X!L?q2;>Ctk5` z*In!;#iz##^>0jUrMOVRUKGED7fakeUAox@y!pZEcWzOeuA&hop(Aknyfl* zNL`l3;A94{M8=jwnVm+^jivvJP3fLu?&<5B2?zlb%ue0=TR{}LG_8thEBbo2 z*P32&A~6a@(^ex0I^6M-xq(V7MO3F7SyG4FXQglUWkl;jM*IA<)G3=>#4vw~I$hSk zDB*dI-%95zF%2hDY3vuBQ`D0TB2>_trcN1A7rjMhCNw=NMyyQ!{3OJtGCNXJ@pBC8p4I!JTr%yj-vZXjCAUKGD#m0T33a=pE( zUVrEzwbxBMV=~Uz5l!lzlc}9<=7yZ)mV|G!qQ zD(rREVPC9XtG--4Sv4zHD{b?C=If?!?km4tK2I4CKmY_l00ck)1V8`;Kw#GqSgvVG z@5I7N;PHj?9jE1bZn)5PV|z6W&hTa&EyUdM=GKDn>|D${ydBb3BZz5tBo9Wh?FYWS zLJv82=;>v--wwGa-t`dL^nwyMXwg&34Mz1W?Q}h!bC%m@f(E^=gX%b5r8*`@s6*J( z>_&0m+41@+k4V?yWrWZ4urHa01P(=rJup--beez42WItXc+2bR~mJLm>z=$26Uf!{~enclhYy?3G zjc)9AWaAV_%&5-Nar|~yyozIW{n0U!V~n7Vw2NK?Kr#+%B%?f{3<47iMULjTVbDNB z9m|oNi4l}Z_|j`JmPLv*I;L(*(tu9l{(r5^6!u{C6?3}s`|>;0Q)XbkQT;P}gL#z| z^JaOi%&Out0Rjks00@8p2!H?xfB*>WHUe#}cS0Tc3|*X^-_`oR*3^1SJ6_N5`ky_a z^(IHDW0UoN^>MAYXJlFZ^?zkMat7A_=62)^tpCd^TJPj2b|ovaE!umN^?&KK);qA1 z6%8!mjAL4Fd=%NYuK$ZiwH_NqP(mYH|LfanoPqU!;jq>-Mp0*A{Xh1Y)+>)HBU}G# z+c9XMq4G;wZ(621fL{~R^>#3+|W5=jF(;r)NRZ3=@uAOHd&00JNY0w4eaAOHd& z00JN|L?Ex`m5R9kudWLIU}G#gNbcvqgnn zXMbmZWq)K>*{|8J*w5L^>?bT_Eq016v4fNW0R%t*1V8`;KmY_l00ck)1V8`;K1~EF zHBB|{uYL7u`_kn{7IdA_W{rI&Z&WF9U-j)PSJY~Sl3%D?diTcVzy0?4Mb$KE`}5|z zPbsRR6sO9x^|kT`H$I{*89sdNotx*bU%PqsUsI(L9sF!*-!Fdp%<+Oj>8ha`MT+k$ zzIpQ7<0V}j*3X=N>EHM31xnWnm-NDz$Tya&YZO1Meem98>vz}34?d-6ma5eLaK$L( zMTWdu&Qbii+_P%_N0T3pshVL_Rr&lsdr4t8*nikR*n8|6`xASMy-C*re#2g)D*?Y` zKVw%Y0|E$u00@8p2!H?xfB*=900@8p2!Oy25)jLJwN{f6V=}5%WmKui$TVeCF3YG? zl96G^s92PdE-wzKg@W7~8 Date: Sun, 7 Jul 2024 17:09:23 +0300 Subject: [PATCH 03/97] test(unit): renamed a test --- .../{test_reminder_period.py => test_update_reminders.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/test_non_handlers/{test_reminder_period.py => test_update_reminders.py} (100%) diff --git a/tests/test_non_handlers/test_reminder_period.py b/tests/test_non_handlers/test_update_reminders.py similarity index 100% rename from tests/test_non_handlers/test_reminder_period.py rename to tests/test_non_handlers/test_update_reminders.py From 9da7dac2b2c92761e23e11b32781f43241a405f8 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Thu, 27 Jun 2024 18:22:07 +0300 Subject: [PATCH 04/97] Add file for keyboards --- bot/keyboards.py | 131 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 bot/keyboards.py diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..e8e7bf8 --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,131 @@ +from typing import List, Dict, Optional +from datetime import datetime, time + +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton +from pytz import timezone, utc + +INCLUDED_1 = "โœ…" +INCLUDED_2 = "๐Ÿ—น" +INCLUDED_3 = "๐ŸŸฉ" +NOT_INCLUDED_1 = "โŽ" +NOT_INCLUDED_2 = "โ˜" +NOT_INCLUDED_3 = "๐ŸŸฅ" +ADD = "โž•" +REMOVE = "โœ–๏ธ" + + +# TODO: merge overlapping intervals +# TODO: sort intervals +class Interval: + def __init__(self, start_time: time, end_time: time, tz: timezone = utc): + self.start_time = start_time + self.end_time = end_time + self.tz = tz + + @classmethod + def from_string(cls, interval_str: str, tz: timezone = utc): + start_str, end_str = interval_str.replace(" ", "").split('-') + start_time = cls.parse_time(start_str) + end_time = cls.parse_time(end_str) + return cls(start_time, end_time, tz) + + @staticmethod + def parse_time(time_str: str) -> time: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + elif '.' in time_str: + return datetime.strptime(time_str, "%H.%M").time() + else: + raise ValueError("Time format must be either HH:MM or HH.MM") + + def to_string(self): + return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" + + def convert_to_timezone(self, new_tz: timezone): + start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) + end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) + new_start_dt = start_dt.astimezone(new_tz) + new_end_dt = end_dt.astimezone(new_tz) + self.start_time = new_start_dt.time() + self.end_time = new_end_dt.time() + self.tz = new_tz + + def overlaps_with(self, other): + if self.tz != other.tz: + raise ValueError("Time zones must match to compare intervals") + + start_a = datetime.combine(datetime.today(), self.start_time) + end_a = datetime.combine(datetime.today(), self.end_time) + start_b = datetime.combine(datetime.today(), other.start_time) + end_b = datetime.combine(datetime.today(), other.end_time) + + return max(start_a, start_b) < min(end_a, end_b) + + def to_keyboard(self, weekday: str) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + interval_str = self.to_string() + + builder.button(text=interval_str, callback_data=f"{weekday}_interval_{interval_str}") + builder.button(text=REMOVE, callback_data=f"{weekday}_remove_{interval_str}") + builder.button(text=ADD, callback_data=f"{weekday}_add") + builder.adjust(3) + + return builder + + def __eq__(self, other): + if not isinstance(other, Interval): + return False + return (self.start_time == other.start_time and + self.end_time == other.end_time and + self.tz == other.tz) + + def __str__(self): + return self.to_string() + + def __repr__(self): + return f"Interval({self.to_string()}, tz={self.tz})" + + +class DaySchedule: + def __init__(self, name: str, included: bool = False, intervals: Optional[List[Interval]] = None): + self.name = name + self.included = included + self.intervals: List[Interval] = intervals if intervals else [] + + def toggle_inclusion(self): + self.included = not self.included + + def add_interval(self, interval: Interval): + self.intervals.append(interval) + + def remove_interval(self, interval: Interval): + self.intervals = [i for i in self.intervals if i != interval] + + def to_keyboard(self) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + included_text = INCLUDED_3 if self.included else NOT_INCLUDED_3 + + day = InlineKeyboardButton(text=f"{self.name}", callback_data=f"{self.name}") + status = InlineKeyboardButton(text=included_text, callback_data=f"{self.name}_toggle") + builder.row(day, status) + + if self.included: + for interval in self.intervals: + interval_builder = interval.to_keyboard(weekday=self.name) + builder.attach(interval_builder) + + return builder + + +class WeekSchedule: + def __init__(self, schedule: Dict[str, DaySchedule]): + self.schedule = schedule + + def to_keyboard(self) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for weekday in self.schedule.values(): + weekday_builder = weekday.to_keyboard() + builder.attach(weekday_builder) + + return builder.as_markup() From a56233a9641a007468436a3a8afc6e40b7579346 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Thu, 27 Jun 2024 18:23:37 +0300 Subject: [PATCH 05/97] Add file for handling user working time setting --- bot/work_time.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 bot/work_time.py diff --git a/bot/work_time.py b/bot/work_time.py new file mode 100644 index 0000000..ae9b49a --- /dev/null +++ b/bot/work_time.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from aiogram import Bot, Router +from aiogram.filters.command import Command +from aiogram.types import Message +from aiogram.utils.i18n import gettext as _ +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from .commands import bot_command_names +from .custom_types import SendMessage +from .filters import HasChatState, HasMessageText, HasMessageUserUsername +from .keyboards import Interval, DaySchedule, WeekSchedule +from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm + + +def handle_working_time( + scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot +): + @router.message( + Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState() + ) + async def show_user_schedule( + message: Message, username: str, chat_state: ChatState + ): + my_schedule = { + "Monday": DaySchedule("Monday", True, [Interval.from_string("09:00 - 18:00")]), + "Tuesday": DaySchedule("Tuesday", False), + "Wednesday": DaySchedule("Wednesday", True, [Interval.from_string("10:00 - 17:00")]), + "Thursday": DaySchedule("Thursday", False), + "Friday": DaySchedule("Friday", True, [Interval.from_string("09:00 - 18:00")]), + "Saturday": DaySchedule("Saturday", False), + "Sunday": DaySchedule("Sunday", False) + } + + week_schedule = WeekSchedule(schedule=my_schedule) + + await message.answer( + text=f"@{username}, here is your schedule:", + reply_markup=week_schedule.to_keyboard() + ) From 162f63bfbc5eb7c15c7da6f9178b8555a7102ec5 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Thu, 27 Jun 2024 18:28:21 +0300 Subject: [PATCH 06/97] Add /set_personal_working_time to commands and modify handlers file for work time settings --- bot/commands.py | 3 +++ bot/handlers.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/bot/commands.py b/bot/commands.py index a7bf67f..f7e39df 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -19,6 +19,7 @@ class BotCommands(BaseModel): join: str skip: str set_personal_meetings_days: str + set_personal_working_time: str set_reminder_period: str join_today: str skip_today: str @@ -47,6 +48,7 @@ class BotCommandNames(BotCommands): join="join", skip="skip", set_personal_meetings_days="set_personal_meetings_days", + set_personal_working_time="set_personal_working_time", set_reminder_period="set_reminder_period", join_today="join_today", skip_today="skip_today", @@ -78,6 +80,7 @@ def bot_command_descriptions() -> BotCommandDescriptions: join=_("Join meetings."), skip=_("Skip meetings."), set_personal_meetings_days=_("Set the days when you can join meetings."), + set_personal_working_time=_("Set your working schedule."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), skip_today=_("Skip only today's meeting."), diff --git a/bot/handlers.py b/bot/handlers.py index 8325793..0f2855e 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -15,6 +15,7 @@ from .filters import HasChatState, HasMessageText, HasMessageUserUsername, IsReplyToMeetingMessage from .meeting import schedule_meeting from .reminder import update_reminders +from .work_time import handle_working_time from .messages import make_help_message from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -34,6 +35,10 @@ def make_router(scheduler: AsyncIOScheduler, send_message: SendMessage, bot: Bot scheduler=scheduler, send_message=send_message, router=router, bot=bot ) + handle_working_time( + scheduler=scheduler, send_message=send_message, router=router, bot=bot + ) + handle_info_commands( scheduler=scheduler, send_message=send_message, router=router ) From b152c0115a73bc122cfe1e8da1c02402fd5e78bc Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:16:37 +0300 Subject: [PATCH 07/97] Added files to store callback dataclasses and pydantic models for time intervals --- bot/callbacks.py | 0 bot/intervals.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 bot/callbacks.py create mode 100644 bot/intervals.py diff --git a/bot/callbacks.py b/bot/callbacks.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/intervals.py b/bot/intervals.py new file mode 100644 index 0000000..e69de29 From 6f5c9ade2c2178cbc24bea094f1eea5d86d30579 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:17:42 +0300 Subject: [PATCH 08/97] Added callbacks for intervals editing and weekday toggling --- bot/callbacks.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/callbacks.py b/bot/callbacks.py index e69de29..f3b2364 100644 --- a/bot/callbacks.py +++ b/bot/callbacks.py @@ -0,0 +1,12 @@ +from aiogram.filters.callback_data import CallbackData + + +class IntervalCallback(CallbackData, prefix="interval"): + weekday: str + interval: str + action: str # add, remove, edit + + +class WeekdayCallback(CallbackData, prefix="weekday"): + weekday: str + action: str # toggle From e4a584bfe8a854919311fd8a25aedbd0979d0f45 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:19:25 +0300 Subject: [PATCH 09/97] Added base models for time intervals and weekdays Time intervals now have methods to merge and sort multiple intervals --- bot/intervals.py | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/bot/intervals.py b/bot/intervals.py index e69de29..69a8db1 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -0,0 +1,134 @@ +from typing import List, Dict +from datetime import datetime, time + +from pydantic import BaseModel, computed_field +from pytz import timezone, utc + + +class Interval(BaseModel): + start_time: time + end_time: time + tz: timezone = utc + + @computed_field + @property + def start_time_utc(self) -> time: + return self.convert_to_utc(self.start_time) + + @computed_field + @property + def end_time_utc(self) -> time: + return self.convert_to_utc(self.end_time) + + def convert_to_utc(self, local_time: time) -> time: + local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=self.tz) + utc_dt = local_dt.astimezone(utc) + return utc_dt.time() + + @classmethod + def from_string(cls, interval_str: str, tz: timezone): + start_str, end_str = interval_str.replace(" ", "").split('-') + start_time = cls.parse_time(start_str) + end_time = cls.parse_time(end_str) + return cls(start_time=start_time, end_time=end_time, tz=tz) + + @staticmethod + def parse_time(time_str: str) -> time: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + elif '.' in time_str: + return datetime.strptime(time_str, "%H.%M").time() + else: + raise ValueError("Time format must be either HH:MM or HH.MM") + + def convert_to_timezone(self, new_tz: timezone): + start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) + end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) + new_start_dt = start_dt.astimezone(new_tz) + new_end_dt = end_dt.astimezone(new_tz) + return Interval(start_time=new_start_dt.time(), end_time=new_end_dt.time(), tz=new_tz) + + def to_string(self): + return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" + + def __hash__(self): + return hash((self.start_time_utc, self.end_time_utc)) + + def __eq__(self, other): + if not isinstance(other, Interval): + return False + return (self.start_time_utc == other.start_time_utc and + self.end_time_utc == other.end_time_utc) + + def __str__(self): + return self.to_string() + + def __repr__(self): + return f"Interval({self.to_string()}, tz={self.tz})" + + def overlaps_with(self, other): + start_a = self.start_time_utc + end_a = self.end_time_utc + start_b = other.start_time_utc + end_b = other.end_time_utc + + return max(start_a, start_b) < min(end_a, end_b) + + @staticmethod + def merge_intervals(intervals): + distinct_tzs = set([interval.tz for interval in intervals]) + + if len(distinct_tzs) != 1: + raise ValueError("Intervals have to have same tz") + + if not intervals: + return [] + + # Sort intervals by start time + sorted_intervals = sorted(intervals, key=lambda x: x.start_time) + + merged_intervals = [sorted_intervals[0]] + for current in sorted_intervals[1:]: + last = merged_intervals[-1] + + if current.overlaps_with(last) or current.start_time <= last.end_time: + # Merge intervals + merged_intervals[-1] = Interval( + start_time=min(last.start_time, current.start_time), + end_time=max(last.end_time, current.end_time), + tz=last.tz + ) + else: + merged_intervals.append(current) + + return merged_intervals + + +class DaySchedule(BaseModel): + name: str + included: bool = True + intervals: List[Interval] = [] + + def toggle_inclusion(self): + self.included = not self.included + + def add_interval(self, interval: Interval): + self.intervals.append(interval) + + def remove_interval(self, interval: Interval): + self.intervals = [i for i in self.intervals if i != interval] + + +class WeekSchedule(BaseModel): + self.schedule: Dict[str, DaySchedule] = dict() + self.tz = tz + self.shift = shift + + for weekday in schedule: + + intervals = [] + for interval in schedule[weekday]["intervals"]: + intervals.append(Interval.from_string(interval, self.tz)) + + day_schedule = DaySchedule(weekday, schedule[weekday]["include"], intervals) + self.schedule[weekday] = day_schedule From 5c62df4f0db4a273a5a7281b3d4c8b1ab3a0ca70 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:20:53 +0300 Subject: [PATCH 10/97] Maded some minor changes to make classe ssimplier --- bot/intervals.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 69a8db1..e2ea97a 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -117,18 +117,3 @@ def add_interval(self, interval: Interval): def remove_interval(self, interval: Interval): self.intervals = [i for i in self.intervals if i != interval] - - -class WeekSchedule(BaseModel): - self.schedule: Dict[str, DaySchedule] = dict() - self.tz = tz - self.shift = shift - - for weekday in schedule: - - intervals = [] - for interval in schedule[weekday]["intervals"]: - intervals.append(Interval.from_string(interval, self.tz)) - - day_schedule = DaySchedule(weekday, schedule[weekday]["include"], intervals) - self.schedule[weekday] = day_schedule From 8d60442415af5b9b98771812c4eef436ecbe9bfd Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:22:02 +0300 Subject: [PATCH 11/97] Add callbacks and inline queries to buttons --- bot/keyboards.py | 137 +++++++++++------------------------------------ 1 file changed, 30 insertions(+), 107 deletions(-) diff --git a/bot/keyboards.py b/bot/keyboards.py index e8e7bf8..eafdd93 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -1,8 +1,6 @@ -from typing import List, Dict, Optional -from datetime import datetime, time - from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton -from pytz import timezone, utc + +from .callbacks import IntervalCallback, WeekdayCallback INCLUDED_1 = "โœ…" INCLUDED_2 = "๐Ÿ—น" @@ -14,118 +12,43 @@ REMOVE = "โœ–๏ธ" -# TODO: merge overlapping intervals -# TODO: sort intervals -class Interval: - def __init__(self, start_time: time, end_time: time, tz: timezone = utc): - self.start_time = start_time - self.end_time = end_time - self.tz = tz - - @classmethod - def from_string(cls, interval_str: str, tz: timezone = utc): - start_str, end_str = interval_str.replace(" ", "").split('-') - start_time = cls.parse_time(start_str) - end_time = cls.parse_time(end_str) - return cls(start_time, end_time, tz) - - @staticmethod - def parse_time(time_str: str) -> time: - if ':' in time_str: - return datetime.strptime(time_str, "%H:%M").time() - elif '.' in time_str: - return datetime.strptime(time_str, "%H.%M").time() - else: - raise ValueError("Time format must be either HH:MM or HH.MM") - - def to_string(self): - return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" - - def convert_to_timezone(self, new_tz: timezone): - start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) - end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) - new_start_dt = start_dt.astimezone(new_tz) - new_end_dt = end_dt.astimezone(new_tz) - self.start_time = new_start_dt.time() - self.end_time = new_end_dt.time() - self.tz = new_tz - - def overlaps_with(self, other): - if self.tz != other.tz: - raise ValueError("Time zones must match to compare intervals") - - start_a = datetime.combine(datetime.today(), self.start_time) - end_a = datetime.combine(datetime.today(), self.end_time) - start_b = datetime.combine(datetime.today(), other.start_time) - end_b = datetime.combine(datetime.today(), other.end_time) - - return max(start_a, start_b) < min(end_a, end_b) - - def to_keyboard(self, weekday: str) -> InlineKeyboardBuilder: - builder = InlineKeyboardBuilder() - interval_str = self.to_string() - - builder.button(text=interval_str, callback_data=f"{weekday}_interval_{interval_str}") - builder.button(text=REMOVE, callback_data=f"{weekday}_remove_{interval_str}") - builder.button(text=ADD, callback_data=f"{weekday}_add") - builder.adjust(3) - - return builder - - def __eq__(self, other): - if not isinstance(other, Interval): - return False - return (self.start_time == other.start_time and - self.end_time == other.end_time and - self.tz == other.tz) - - def __str__(self): - return self.to_string() +def get_interval_keyboard(interval: str, weekday: str) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + interval_std = interval.replace(":", "|") - def __repr__(self): - return f"Interval({self.to_string()}, tz={self.tz})" + builder.button(text=interval, switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}") + builder.button(text=REMOVE, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='remove')) + builder.button(text=ADD, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='add')) + builder.adjust(3) + return builder -class DaySchedule: - def __init__(self, name: str, included: bool = False, intervals: Optional[List[Interval]] = None): - self.name = name - self.included = included - self.intervals: List[Interval] = intervals if intervals else [] - def toggle_inclusion(self): - self.included = not self.included +def get_weekday_keyboard(weekday: str, content: dict) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + included_text = INCLUDED_3 if content["include"] else NOT_INCLUDED_3 - def add_interval(self, interval: Interval): - self.intervals.append(interval) + day = InlineKeyboardButton(text=f"{weekday}", callback_data="#") + status = InlineKeyboardButton( + text=included_text, + callback_data=WeekdayCallback(weekday=weekday, action="toggle").pack() + ) - def remove_interval(self, interval: Interval): - self.intervals = [i for i in self.intervals if i != interval] + builder.row(day, status) - def to_keyboard(self) -> InlineKeyboardBuilder: - builder = InlineKeyboardBuilder() - included_text = INCLUDED_3 if self.included else NOT_INCLUDED_3 + if content["include"]: + for interval in content["intervals"]: + interval_builder = get_interval_keyboard(interval, weekday) + builder.attach(interval_builder) - day = InlineKeyboardButton(text=f"{self.name}", callback_data=f"{self.name}") - status = InlineKeyboardButton(text=included_text, callback_data=f"{self.name}_toggle") - builder.row(day, status) + return builder - if self.included: - for interval in self.intervals: - interval_builder = interval.to_keyboard(weekday=self.name) - builder.attach(interval_builder) - - return builder - - -class WeekSchedule: - def __init__(self, schedule: Dict[str, DaySchedule]): - self.schedule = schedule - def to_keyboard(self) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() +def get_schedule_keyboard(week_schedule: dict) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() - for weekday in self.schedule.values(): - weekday_builder = weekday.to_keyboard() - builder.attach(weekday_builder) + for weekday, content in week_schedule.items(): + weekday_builder = get_weekday_keyboard(weekday, content) + builder.attach(weekday_builder) - return builder.as_markup() + return builder.as_markup() From e8fd73a3980096e6a7e2b10532fce98642b671f5 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:22:53 +0300 Subject: [PATCH 12/97] Add all necessary handlers for menu with schedule --- bot/work_time.py | 160 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 23 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index ae9b49a..fb9fd18 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -1,40 +1,154 @@ +import re from datetime import datetime -from aiogram import Bot, Router +from aiogram import Bot, Router, F from aiogram.filters.command import Command -from aiogram.types import Message +from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pytz import timezone, utc from .commands import bot_command_names from .custom_types import SendMessage -from .filters import HasChatState, HasMessageText, HasMessageUserUsername -from .keyboards import Interval, DaySchedule, WeekSchedule +from .callbacks import IntervalCallback, WeekdayCallback +# from .intervals import WeekSchedule, DaySchedule, Interval +from .filters import HasChatState, HasMessageUserUsername +from .keyboards import get_schedule_keyboard from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm +EDIT_HANDLE_IVL = \ + r'^edit\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\s*-->\s*\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' + +EDIT_PARSE_IVL = \ + r'^edit\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})\s*-->' \ + r'\s*(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' + +ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' +ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' + +schedule_db = { + "Monday": { + "include": True, + "intervals": ["9:00 - 18:00"] + }, + "Tuesday": { + "include": False, + "intervals": [] + }, + "Wednesday": { + "include": True, + "intervals": ["10:00 - 17:00"] + }, + "Thursday": { + "include": False, + "intervals": [] + }, + "Friday": { + "include": True, + "intervals": ["9:00 - 18:00"] + }, + "Saturday": { + "include": False, + "intervals": [] + }, + "Sunday": { + "include": False, + "intervals": [] + }, + } + +chat_db = { + "schedule_msg": None, + "tz": timezone("Europe/Moscow"), + "shift": 0 +} + + def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message( - Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState() - ) - async def show_user_schedule( - message: Message, username: str, chat_state: ChatState - ): - my_schedule = { - "Monday": DaySchedule("Monday", True, [Interval.from_string("09:00 - 18:00")]), - "Tuesday": DaySchedule("Tuesday", False), - "Wednesday": DaySchedule("Wednesday", True, [Interval.from_string("10:00 - 17:00")]), - "Thursday": DaySchedule("Thursday", False), - "Friday": DaySchedule("Friday", True, [Interval.from_string("09:00 - 18:00")]), - "Saturday": DaySchedule("Saturday", False), - "Sunday": DaySchedule("Sunday", False) - } + @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) + async def show_user_schedule(message: Message, username: str, chat_state: ChatState): + # sch = WeekSchedule(schedule_db, tz=utc, shift=0) + for weekday in schedule_db: + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + chat_db["schedule_msg"] = schedule_msg - week_schedule = WeekSchedule(schedule=my_schedule) + @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) + async def show_inline_interval_editing(inline_query: InlineQuery): - await message.answer( - text=f"@{username}, here is your schedule:", - reply_markup=week_schedule.to_keyboard() + suggestion = InlineQueryResultArticle( + id=inline_query.query, + title=inline_query.query, + input_message_content=InputTextMessageContent( + message_text=inline_query.query + ) ) + await inline_query.answer([suggestion], is_personal=True) + + @router.message(F.text.regexp(EDIT_HANDLE_IVL), HasMessageUserUsername(), HasChatState()) + async def handle_interval_editing(message: Message, username: str, chat_state: ChatState): + + parse_pattern = re.compile(EDIT_PARSE_IVL) + parse_match = parse_pattern.match(message.text) + if parse_match: + weekday = parse_match.group("weekday") + st_time_prev = parse_match.group("start_time1") + end_time_prev = parse_match.group("end_time1") + st_time_edit = parse_match.group("start_time2") + end_time_edit = parse_match.group("end_time2") + + interval_prev = f"{st_time_prev} - {end_time_prev}" + interval_edit = f"{st_time_edit} - {end_time_edit}" + + intervals = schedule_db[weekday]["intervals"] + if interval_prev in intervals: + intervals.remove(interval_prev) + intervals.append(interval_edit) + schedule_db[weekday]["intervals"] = intervals + + layout = get_schedule_keyboard(schedule_db) + if chat_db["schedule_msg"]: + old_msg = chat_db["schedule_msg"] + await old_msg.edit_reply_markup(reply_markup=layout) + + await message.delete() + + @router.callback_query(IntervalCallback.filter(F.action == 'add')) + async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): + + weekday = callback_data.weekday + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + await cb.message.edit_reply_markup(reply_markup=layout) + + @router.callback_query(IntervalCallback.filter(F.action == 'remove')) + async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): + + weekday = callback_data.weekday + interval = callback_data.interval.replace("|", ":") + schedule_db[weekday]["intervals"].remove(interval) + + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + await cb.message.edit_reply_markup(reply_markup=layout) + + @router.callback_query(WeekdayCallback.filter(F.action == 'toggle')) + async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): + + weekday = callback_data.weekday + schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] + + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + await cb.message.edit_reply_markup(reply_markup=layout) From 2a8a803974259b30367d031351aeb390f04b6d09 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:24:06 +0300 Subject: [PATCH 13/97] Fix bug with phantom notifications (again) --- bot/reminder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/reminder.py b/bot/reminder.py index f3bb40d..6c90f07 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -62,7 +62,7 @@ async def send_reminder_messages( reply_to_message_id=message_id ) - if chat_type == "supergroup" and len(chat_state.meeting_msg_ids) == 3: + if chat_type == "supergroup" and len(chat_state.meeting_msg_ids) == 3 and len(have_to_reply) > 0: await send_message( chat_id=user_chat_id, message=reminder_message From 0212ae8aa9ed3d9fc95e8568abb26447248166c0 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:24:45 +0300 Subject: [PATCH 14/97] Added interval validation and exceptions --- bot/intervals.py | 76 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index e2ea97a..94d54fa 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,14 +1,36 @@ -from typing import List, Dict +from typing import List from datetime import datetime, time -from pydantic import BaseModel, computed_field -from pytz import timezone, utc +from pydantic import BaseModel, computed_field, field_validator +from pytz import timezone, utc, UnknownTimeZoneError + + +class IntervalException(Exception): + """Base class for other Interval exceptions""" + pass + + +class InvalidTimeFormatException(IntervalException): + """Raised when the time format is invalid""" + def __init__(self, time_str, message="Time format must be either HH:MM or HH.MM"): + self.time_str = time_str + self.message = message + super().__init__(self.message) + + +class InvalidIntervalException(IntervalException): + """Raised when the interval is invalid""" + def __init__(self, start_time, end_time, message="Start time must be earlier than end time"): + self.start_time = start_time + self.end_time = end_time + self.message = message + super().__init__(self.message) class Interval(BaseModel): start_time: time end_time: time - tz: timezone = utc + tz: str = "UTC" @computed_field @property @@ -20,32 +42,46 @@ def start_time_utc(self) -> time: def end_time_utc(self) -> time: return self.convert_to_utc(self.end_time) + @field_validator("tz") + def zone_must_be_valid(cls, tz: str): + try: + timezone(tz) + except UnknownTimeZoneError: + raise ValueError("You should pass valid zone name") + def convert_to_utc(self, local_time: time) -> time: - local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=self.tz) + local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=timezone(self.tz)) utc_dt = local_dt.astimezone(utc) return utc_dt.time() @classmethod - def from_string(cls, interval_str: str, tz: timezone): + def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') start_time = cls.parse_time(start_str) end_time = cls.parse_time(end_str) + + if start_time >= end_time: + raise InvalidIntervalException(start_time, end_time) + return cls(start_time=start_time, end_time=end_time, tz=tz) @staticmethod def parse_time(time_str: str) -> time: - if ':' in time_str: - return datetime.strptime(time_str, "%H:%M").time() - elif '.' in time_str: - return datetime.strptime(time_str, "%H.%M").time() - else: - raise ValueError("Time format must be either HH:MM or HH.MM") - - def convert_to_timezone(self, new_tz: timezone): - start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) - end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) - new_start_dt = start_dt.astimezone(new_tz) - new_end_dt = end_dt.astimezone(new_tz) + try: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + elif '.' in time_str: + return datetime.strptime(time_str, "%H.%M").time() + else: + raise InvalidTimeFormatException(time_str) + except ValueError: + raise InvalidTimeFormatException(time_str) + + def convert_to_timezone(self, new_tz: str): + start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=timezone(self.tz)) + end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=timezone(self.tz)) + new_start_dt = start_dt.astimezone(timezone(new_tz)) + new_end_dt = end_dt.astimezone(timezone(new_tz)) return Interval(start_time=new_start_dt.time(), end_time=new_end_dt.time(), tz=new_tz) def to_string(self): @@ -76,10 +112,10 @@ def overlaps_with(self, other): @staticmethod def merge_intervals(intervals): - distinct_tzs = set([interval.tz for interval in intervals]) + distinct_tzs = set([timezone(interval.tz).utcoffset(datetime.now()) for interval in intervals]) if len(distinct_tzs) != 1: - raise ValueError("Intervals have to have same tz") + raise ValueError("Intervals have to have same timezone offset") if not intervals: return [] From 5634a9aa3acf6ba9da5e987eb2e7f2d1be83056e Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:25:48 +0300 Subject: [PATCH 15/97] Add messages if user entered invalid interval --- bot/messages.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bot/messages.py b/bot/messages.py index 8c10d42..de8e84c 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from datetime import datetime from textwrap import dedent -from typing import List +from typing import List, Tuple from aiogram import html from aiogram.utils.i18n import gettext as _ @@ -9,6 +9,7 @@ from .commands import bot_command_descriptions, bot_command_names from .constants import day_of_week_pretty from .language import Language +from .intervals import Interval, InvalidTimeFormatException, InvalidIntervalException def bot_intro(): @@ -56,3 +57,23 @@ def make_daily_messages(usernames: str) -> List[str]: usernames=usernames ), ] + + +def make_interval_validation_message(interval_str: str, tz: str) -> Tuple[bool, str]: + try: + interval = Interval.from_string(interval_str=interval_str, tz=tz) + msg = _("Successfully parsed interval: {interval}").format(interval=interval) + return True, msg + except InvalidTimeFormatException as e: + msg = _("Error: Invalid time format for '{time}'. {msg}").format(time=e.time_str, msg=e.message) + return False, msg + except InvalidIntervalException as e: + msg = _("Error: {msg} (start: {start}, end: {end})").format( + msg=e.message, + start=e.start_time, + end=e.end_time + ) + return False, msg + except Exception as e: + msg = _("Error: An unexpected error occurred. {error}").format(error=str(e)) + return False, msg From 3fb3a71655524ec98a3c60303d702492657b4d05 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:27:37 +0300 Subject: [PATCH 16/97] Add keyboard markup for wrong interval input --- bot/keyboards.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/keyboards.py b/bot/keyboards.py index eafdd93..a8fcaca 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -52,3 +52,15 @@ def get_schedule_keyboard(week_schedule: dict) -> InlineKeyboardMarkup: builder.attach(weekday_builder) return builder.as_markup() + + +def get_interval_error_keyboard(interval: str, weekday: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button(text="Cancel", callback_data="cancel_editing_interval") + builder.button( + text="Enter the interval again", + switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}" + ) + + return builder.adjust(2).as_markup() From e56994d8540a17282bf4c6292b6cf9de4b9a94f5 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:29:03 +0300 Subject: [PATCH 17/97] Add handlers for wrong input and reentering intervals --- bot/work_time.py | 72 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index fb9fd18..9159fe9 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -3,17 +3,18 @@ from aiogram import Bot, Router, F from aiogram.filters.command import Command +from aiogram.fsm.context import FSMContext from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler -from pytz import timezone, utc from .commands import bot_command_names from .custom_types import SendMessage +from .messages import make_interval_validation_message from .callbacks import IntervalCallback, WeekdayCallback -# from .intervals import WeekSchedule, DaySchedule, Interval +from .intervals import Interval from .filters import HasChatState, HasMessageUserUsername -from .keyboards import get_schedule_keyboard +from .keyboards import get_schedule_keyboard, get_interval_error_keyboard from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -26,6 +27,7 @@ ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' +DEFAULT_INTERVAL = "23:59 - 23:59" schedule_db = { "Monday": { @@ -55,13 +57,16 @@ "Sunday": { "include": False, "intervals": [] - }, + } } chat_db = { + "default_working_time": "9:00 - 17:00", "schedule_msg": None, - "tz": timezone("Europe/Moscow"), - "shift": 0 + "tz": "Europe/Moscow", + "shift": 0, + "edit_interval_message": None, + "message_with_keyboard": None } @@ -70,10 +75,10 @@ def handle_working_time( ): @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) async def show_user_schedule(message: Message, username: str, chat_state: ChatState): - # sch = WeekSchedule(schedule_db, tz=utc, shift=0) + for weekday in schedule_db: if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) @@ -81,6 +86,12 @@ async def show_user_schedule(message: Message, username: str, chat_state: ChatSt @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) async def show_inline_interval_editing(inline_query: InlineQuery): + message_with_keyboard = chat_db["message_with_keyboard"] + edit_interval_message = chat_db["edit_interval_message"] + + if message_with_keyboard is not None and edit_interval_message is not None: + await message_with_keyboard.delete() + await edit_interval_message.delete() suggestion = InlineQueryResultArticle( id=inline_query.query, @@ -106,24 +117,35 @@ async def handle_interval_editing(message: Message, username: str, chat_state: C interval_prev = f"{st_time_prev} - {end_time_prev}" interval_edit = f"{st_time_edit} - {end_time_edit}" - intervals = schedule_db[weekday]["intervals"] - if interval_prev in intervals: - intervals.remove(interval_prev) - intervals.append(interval_edit) - schedule_db[weekday]["intervals"] = intervals + is_valid, status_msg = make_interval_validation_message(interval_str=interval_edit, tz=chat_db["tz"]) + + if is_valid: + + intervals = schedule_db[weekday]["intervals"] + if interval_prev in intervals: + intervals.remove(interval_prev) + intervals.append(interval_edit) + schedule_db[weekday]["intervals"] = intervals - layout = get_schedule_keyboard(schedule_db) - if chat_db["schedule_msg"]: - old_msg = chat_db["schedule_msg"] - await old_msg.edit_reply_markup(reply_markup=layout) + layout = get_schedule_keyboard(schedule_db) + if chat_db["schedule_msg"]: + old_msg = chat_db["schedule_msg"] + await old_msg.edit_reply_markup(reply_markup=layout) - await message.delete() + await message.delete() + + else: + layout = get_interval_error_keyboard(interval_prev, weekday) + keyboard_msg = await message.reply(text=status_msg, reply_markup=layout) + + chat_db["edit_interval_message"] = message + chat_db["message_with_keyboard"] = keyboard_msg @router.callback_query(IntervalCallback.filter(F.action == 'add')) async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): weekday = callback_data.weekday - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @@ -136,7 +158,7 @@ async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): schedule_db[weekday]["intervals"].remove(interval) if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @@ -148,7 +170,15 @@ async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) + + @router.callback_query(F.data == "cancel_editing_interval") + async def cancel_editing_interval(cb: CallbackQuery): + message_with_keyboard = cb.message + edit_interval_message = cb.message.reply_to_message + + await message_with_keyboard.delete() + await edit_interval_message.delete() From 263d5c598c9156af0c1f0c713e2cf0ab5591ae90 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 17:45:52 +0300 Subject: [PATCH 18/97] Added default intervals handling --- bot/commands.py | 6 +++ bot/constants.py | 4 ++ bot/work_time.py | 105 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/bot/commands.py b/bot/commands.py index f7e39df..654a925 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -15,11 +15,13 @@ class BotCommands(BaseModel): set_meetings_time_zone: str set_meetings_time: str set_meetings_days: str + set_default_working_time: str # personal settings join: str skip: str set_personal_meetings_days: str set_personal_working_time: str + set_personal_default_working_time: str set_reminder_period: str join_today: str skip_today: str @@ -44,11 +46,13 @@ class BotCommandNames(BotCommands): set_meetings_time_zone="set_meetings_time_zone", set_meetings_time="set_meetings_time", set_meetings_days="set_meetings_days", + set_default_working_time="set_default_working_time", # personal settings join="join", skip="skip", set_personal_meetings_days="set_personal_meetings_days", set_personal_working_time="set_personal_working_time", + set_personal_default_working_time="set_personal_default_working_time", set_reminder_period="set_reminder_period", join_today="join_today", skip_today="skip_today", @@ -76,11 +80,13 @@ def bot_command_descriptions() -> BotCommandDescriptions: set_meetings_time_zone=_("Set meetings time zone."), set_meetings_time=_("Set meetings time."), set_meetings_days=_("Set meetings days."), + set_default_working_time=_("Set default working interval for weekday."), # personal settings join=_("Join meetings."), skip=_("Skip meetings."), set_personal_meetings_days=_("Set the days when you can join meetings."), set_personal_working_time=_("Set your working schedule."), + set_personal_default_working_time=_("Set personal default working interval for weekday."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), skip_today=_("Skip only today's meeting."), diff --git a/bot/constants.py b/bot/constants.py index 5e7703e..648ab2c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -15,6 +15,10 @@ class AppCommands: iso8601 = "ISO 8601" +interval_format = "HH:MM or HH.MM" + +sample_interval = "9:00 - 17:00" + datetime_time_format = "%H:%M %Y-%m-%d" day_of_week = "0-6" diff --git a/bot/work_time.py b/bot/work_time.py index 9159fe9..2230c08 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -1,4 +1,5 @@ import re +from textwrap import dedent from datetime import datetime from aiogram import Bot, Router, F @@ -12,8 +13,9 @@ from .custom_types import SendMessage from .messages import make_interval_validation_message from .callbacks import IntervalCallback, WeekdayCallback +from .constants import interval_format, sample_interval from .intervals import Interval -from .filters import HasChatState, HasMessageUserUsername +from .filters import HasChatState, HasMessageUserUsername, HasMessageText from .keyboards import get_schedule_keyboard, get_interval_error_keyboard from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -27,7 +29,6 @@ ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' -DEFAULT_INTERVAL = "23:59 - 23:59" schedule_db = { "Monday": { @@ -62,6 +63,7 @@ chat_db = { "default_working_time": "9:00 - 17:00", + "personal_default_working_time": "8:00 - 18:00", "schedule_msg": None, "tz": "Europe/Moscow", "shift": 0, @@ -73,16 +75,87 @@ def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): + @router.message(Command(bot_command_names.set_default_working_time), HasMessageText()) + async def set_default_working_time(message: Message, message_text: str): + cmd = "/" + bot_command_names.set_default_working_time + interval = message_text.replace(cmd, "").strip() + + is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") + if is_valid: + chat_db["default_working_time"] = interval + await message.answer( + _( + "Your group default working time is set to {interval}" + ).format(interval=interval) + ) + else: + await message.answer( + dedent( + _( + f""" + Please write group default working time in {interval_format} format. + + Example: + + /{set_default_working_time} {sample_interval} + """ + ).format( + interval_format=interval_format, + set_default_working_time=bot_command_names.set_default_working_time, + sample_interval=sample_interval + ) + ) + ) + + @router.message(Command(bot_command_names.set_personal_default_working_time), HasMessageText()) + async def set_personal_default_working_time(message: Message, message_text: str): + cmd = "/" + bot_command_names.set_personal_default_working_time + interval = message_text.replace(cmd, "").strip() + + is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") + if is_valid: + chat_db["personal_default_working_time"] = interval + await message.answer( + _( + "Your personal default working time is set to {interval}" + ).format(interval=interval) + ) + else: + await message.answer( + dedent( + _( + f""" + Please write your personal default working time in {interval_format} format. + + Example: + + /{set_personal_default_working_time} {sample_interval} + """ + ).format( + interval_format=interval_format, + set_default_working_time=bot_command_names.set_personal_default_working_time, + sample_interval=sample_interval + ) + ) + ) + @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) async def show_user_schedule(message: Message, username: str, chat_state: ChatState): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] - for weekday in schedule_db: - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + if personal_default_interval is not None or group_default_interval is not None: + default = personal_default_interval if personal_default_interval is not None else group_default_interval - layout = get_schedule_keyboard(schedule_db) - schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) - chat_db["schedule_msg"] = schedule_msg + for weekday in schedule_db: + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append(default) + + layout = get_schedule_keyboard(schedule_db) + schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + chat_db["schedule_msg"] = schedule_msg + else: + await message.answer("You should set up default group or personal working time first!") @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) async def show_inline_interval_editing(inline_query: InlineQuery): @@ -144,33 +217,43 @@ async def handle_interval_editing(message: Message, username: str, chat_state: C @router.callback_query(IntervalCallback.filter(F.action == 'add')) async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] + default = personal_default_interval if personal_default_interval is not None else group_default_interval + weekday = callback_data.weekday - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + schedule_db[weekday]["intervals"].append(default) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @router.callback_query(IntervalCallback.filter(F.action == 'remove')) async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] + default = personal_default_interval if personal_default_interval is not None else group_default_interval weekday = callback_data.weekday interval = callback_data.interval.replace("|", ":") schedule_db[weekday]["intervals"].remove(interval) if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + schedule_db[weekday]["intervals"].append(default) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @router.callback_query(WeekdayCallback.filter(F.action == 'toggle')) async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] + default = personal_default_interval if personal_default_interval is not None else group_default_interval weekday = callback_data.weekday schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + schedule_db[weekday]["intervals"].append(default) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) From 818108772329c94bf2478fb1ee0c9d93b9f60288 Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Mon, 1 Jul 2024 20:52:11 +0300 Subject: [PATCH 19/97] Added fields in db --- bot/constants.py | 12 ++++++++++++ bot/handlers.py | 2 +- bot/intervals.py | 2 +- bot/state.py | 10 +++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 648ab2c..39aabb2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,4 +1,5 @@ from aiogram import html +from bot.intervals import DaySchedule ENCODING = "utf-8" @@ -30,3 +31,14 @@ class AppCommands: } sample_time = "2024-06-03T14:00:00+03:00" + +default_time_zone = "Europe/Moscow" + +days_array = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + +empty_day_schedule = [DaySchedule() for _ in range(len(days_array))] + +for i in range(len(days_array)): + empty_day_schedule[i].name = days_array[i] + +default_user_schedule = empty_day_schedule diff --git a/bot/handlers.py b/bot/handlers.py index 0f2855e..3b6581b 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -131,7 +131,7 @@ async def set_meetings_time( Please write the meetings time in the {iso8601} format with an offset relative to the UTC time zone. You can calculate the time on the site {time_url}. - + Example: /{set_meetings_time} {sample_time} diff --git a/bot/intervals.py b/bot/intervals.py index 94d54fa..921eb0c 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -141,7 +141,7 @@ def merge_intervals(intervals): class DaySchedule(BaseModel): - name: str + name: str = "" included: bool = True intervals: List[Interval] = [] diff --git a/bot/state.py b/bot/state.py index 532d38b..245c483 100644 --- a/bot/state.py +++ b/bot/state.py @@ -8,11 +8,16 @@ from .chat import ChatId from .language import Language +from .intervals import DaySchedule, Interval +from .constants import default_time_zone, default_user_schedule class ChatUser(BaseModel): username: str = "" is_joined: bool = False + schedule: List[DaySchedule] = default_user_schedule + personal_default_working_time: Optional[Interval] = None + time_zone_shift: int = 0 meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday reminder_period: Optional[int] = None non_replied_daily_msgs: set[int] = set(range(0, 3)) @@ -42,6 +47,8 @@ async def create_user(username: str) -> ChatUser: class ChatState(Document): language: Language = Language.default + time_zone: str = default_time_zone + default_working_time: Optional[Interval] = None meeting_time: Optional[datetime] = None meeting_msg_ids: list[int] = [] topic_id: Optional[int] = None @@ -119,13 +126,14 @@ async def save_state(chat_state: ChatState) -> None: Args: chat_state (ChatState): The chat state instance to save. """ - + await chat_state.save() class UserPM(Document): username: str chat_id: Annotated[ChatId, Indexed(index_type=pymongo.ASCENDING)] + personal_time_zone: str = default_time_zone async def create_user_pm(username: str, chat_id: ChatId) -> UserPM: From c2dd3362bddf9ca5020de815134ea3689c687fb4 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Wed, 3 Jul 2024 00:50:40 +0300 Subject: [PATCH 20/97] Added middlewares file and connected them with dispatcher --- bot/bot.py | 3 +++ bot/handlers.py | 8 ++++++++ bot/middlewares.py | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 bot/middlewares.py diff --git a/bot/bot.py b/bot/bot.py index b3c7f32..e5ad775 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -14,6 +14,7 @@ from .constants import jobstore from .custom_types import ChatId, SendMessage from .meeting import schedule_meeting +from .middlewares import GroupCommandFilterMiddleware from .settings import Settings from .state import ChatState @@ -63,6 +64,8 @@ async def main(settings: Settings) -> None: async def send_message(chat_id: ChatId, message: str, message_thread_id: Optional[int] = None): return await bot.send_message(chat_id=chat_id, text=message, message_thread_id=message_thread_id) + dp.update.outer_middleware(GroupCommandFilterMiddleware()) + scheduler = init_scheduler(settings=settings) await restore_scheduled_jobs(scheduler=scheduler, send_message=send_message) diff --git a/bot/handlers.py b/bot/handlers.py index 3b6581b..85b9a23 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -55,6 +55,14 @@ def handle_global_commands( ): @router.message(Command(bot_command_names.start), HasChatState()) async def start(message: Message, chat_state: ChatState): + if message.chat.type == "group": + await message.answer( + "Unfortunately, only supergroups and private chats are supported. " + "Please promote this group to a supergroup by enabling the history of messages for new members " + "or by enabling topics." + ) + return + await get_help(message=message, chat_state=chat_state) # Register user if it is personal message diff --git a/bot/middlewares.py b/bot/middlewares.py new file mode 100644 index 0000000..6f71dcb --- /dev/null +++ b/bot/middlewares.py @@ -0,0 +1,21 @@ +from typing import Any, Callable, Dict, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update + + +class GroupCommandFilterMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + if isinstance(event, Update) and event.message: + chat = event.message.chat + text = event.message.text + + if chat.type == "group" and not text.startswith("/start"): + return + + return await handler(event, data) From 03e8af1c0831d88ad45684428c9097aa3b497478 Mon Sep 17 00:00:00 2001 From: fullerite Date: Mon, 1 Jul 2024 20:32:25 +0300 Subject: [PATCH 21/97] feat(topics): store separate chat states for supergroup topics --- bot/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/state.py b/bot/state.py index 245c483..bf32e0b 100644 --- a/bot/state.py +++ b/bot/state.py @@ -145,4 +145,4 @@ async def load_user_pm(username: str) -> Optional[UserPM]: async def save_user_pm(user_pm: UserPM) -> None: - await user_pm.save() \ No newline at end of file + await user_pm.save() From 722553bc55819264a96f174d197fde10b20e1108 Mon Sep 17 00:00:00 2001 From: fullerite Date: Mon, 1 Jul 2024 23:28:15 +0300 Subject: [PATCH 22/97] feat(topics): send reminders w.r.t topics --- .vscode/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index fc4c9b2..e3ec487 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,5 @@ } }, "terminal.integrated.scrollback": 100000, - "workbench.sideBar.location": "right" + "python.analysis.typeCheckingMode": "basic", } \ No newline at end of file From 261a56832f8f76b6816fe60d7c9edb0f7680a766 Mon Sep 17 00:00:00 2001 From: Fullerite <99542745+Fullerite@users.noreply.github.com> Date: Tue, 2 Jul 2024 15:34:32 +0300 Subject: [PATCH 23/97] fix(configs): revert accidental change in settings configuration file --- .vscode/settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e3ec487..288f5f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,5 @@ } }, "terminal.integrated.scrollback": 100000, - "python.analysis.typeCheckingMode": "basic", -} \ No newline at end of file + "workbench.sideBar.location": "right" +} From 40f8659b756f53bba2f503a086582a6eee2fded9 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Wed, 3 Jul 2024 18:34:11 +0300 Subject: [PATCH 24/97] Edit Interval class to use datetime instead of datetime.time --- bot/intervals.py | 53 +++++++++++++++++++++++++----------------------- bot/state.py | 1 - bot/work_time.py | 3 +-- 3 files changed, 29 insertions(+), 28 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 921eb0c..d0fdded 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -12,7 +12,7 @@ class IntervalException(Exception): class InvalidTimeFormatException(IntervalException): """Raised when the time format is invalid""" - def __init__(self, time_str, message="Time format must be either HH:MM or HH.MM"): + def __init__(self, time_str: str, message="Time format must be either HH:MM or HH.MM"): self.time_str = time_str self.message = message super().__init__(self.message) @@ -20,26 +20,29 @@ def __init__(self, time_str, message="Time format must be either HH:MM or HH.MM" class InvalidIntervalException(IntervalException): """Raised when the interval is invalid""" - def __init__(self, start_time, end_time, message="Start time must be earlier than end time"): - self.start_time = start_time - self.end_time = end_time - self.message = message + def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time"): + self.start_time: time = start_time + self.end_time: time = end_time + self.message: str = message super().__init__(self.message) +IMMUTABLE_DATE = datetime(year=2024, month=1, day=1) + + class Interval(BaseModel): - start_time: time - end_time: time + start_time: datetime + end_time: datetime tz: str = "UTC" @computed_field @property - def start_time_utc(self) -> time: + def start_time_utc(self) -> datetime: return self.convert_to_utc(self.start_time) @computed_field @property - def end_time_utc(self) -> time: + def end_time_utc(self) -> datetime: return self.convert_to_utc(self.end_time) @field_validator("tz") @@ -49,19 +52,19 @@ def zone_must_be_valid(cls, tz: str): except UnknownTimeZoneError: raise ValueError("You should pass valid zone name") - def convert_to_utc(self, local_time: time) -> time: - local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=timezone(self.tz)) + def convert_to_utc(self, local_time: datetime) -> datetime: + local_dt = local_time.replace(tzinfo=timezone(self.tz)) utc_dt = local_dt.astimezone(utc) - return utc_dt.time() + return utc_dt @classmethod def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') - start_time = cls.parse_time(start_str) - end_time = cls.parse_time(end_str) + start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str)) + end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str)) if start_time >= end_time: - raise InvalidIntervalException(start_time, end_time) + raise InvalidIntervalException(start_time.time(), end_time.time()) return cls(start_time=start_time, end_time=end_time, tz=tz) @@ -78,11 +81,11 @@ def parse_time(time_str: str) -> time: raise InvalidTimeFormatException(time_str) def convert_to_timezone(self, new_tz: str): - start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=timezone(self.tz)) - end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=timezone(self.tz)) + start_dt = self.start_time.replace(tzinfo=timezone(self.tz)) + end_dt = self.end_time.replace(tzinfo=timezone(self.tz)) new_start_dt = start_dt.astimezone(timezone(new_tz)) new_end_dt = end_dt.astimezone(timezone(new_tz)) - return Interval(start_time=new_start_dt.time(), end_time=new_end_dt.time(), tz=new_tz) + return Interval(start_time=new_start_dt, end_time=new_end_dt, tz=new_tz) def to_string(self): return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" @@ -103,10 +106,10 @@ def __repr__(self): return f"Interval({self.to_string()}, tz={self.tz})" def overlaps_with(self, other): - start_a = self.start_time_utc - end_a = self.end_time_utc - start_b = other.start_time_utc - end_b = other.end_time_utc + start_a = self.start_time_utc.time() + end_a = self.end_time_utc.time() + start_b = other.start_time_utc.time() + end_b = other.end_time_utc.time() return max(start_a, start_b) < min(end_a, end_b) @@ -121,13 +124,13 @@ def merge_intervals(intervals): return [] # Sort intervals by start time - sorted_intervals = sorted(intervals, key=lambda x: x.start_time) + sorted_intervals = sorted(intervals, key=lambda x: x.start_time.time()) merged_intervals = [sorted_intervals[0]] for current in sorted_intervals[1:]: last = merged_intervals[-1] - if current.overlaps_with(last) or current.start_time <= last.end_time: + if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): # Merge intervals merged_intervals[-1] = Interval( start_time=min(last.start_time, current.start_time), @@ -152,4 +155,4 @@ def add_interval(self, interval: Interval): self.intervals.append(interval) def remove_interval(self, interval: Interval): - self.intervals = [i for i in self.intervals if i != interval] + self.intervals.remove(interval) diff --git a/bot/state.py b/bot/state.py index bf32e0b..2d10aff 100644 --- a/bot/state.py +++ b/bot/state.py @@ -1,6 +1,5 @@ from datetime import datetime from typing import Annotated, Optional, Dict, List -from zoneinfo import ZoneInfo import pymongo from beanie import Document, Indexed diff --git a/bot/work_time.py b/bot/work_time.py index 2230c08..f9629b6 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -4,7 +4,6 @@ from aiogram import Bot, Router, F from aiogram.filters.command import Command -from aiogram.fsm.context import FSMContext from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -75,7 +74,7 @@ def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message(Command(bot_command_names.set_default_working_time), HasMessageText()) + @router.message(Command(bot_command_names.set_default_working_time), HasMessageText(), HasMessageUserUsername()) async def set_default_working_time(message: Message, message_text: str): cmd = "/" + bot_command_names.set_default_working_time interval = message_text.replace(cmd, "").strip() From e645f119c05dfbd5a7baef8d034db7cf7207fc1c Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 5 Jul 2024 10:41:46 +0300 Subject: [PATCH 25/97] (feat) Modified merge intervals function to merge only unique intervals --- bot/intervals.py | 95 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index d0fdded..520057c 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,8 +1,10 @@ from typing import List +from collections import Counter from datetime import datetime, time +from zoneinfo import ZoneInfo from pydantic import BaseModel, computed_field, field_validator -from pytz import timezone, utc, UnknownTimeZoneError +from pytz import timezone, UnknownTimeZoneError class IntervalException(Exception): @@ -21,8 +23,8 @@ def __init__(self, time_str: str, message="Time format must be either HH:MM or H class InvalidIntervalException(IntervalException): """Raised when the interval is invalid""" def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time"): - self.start_time: time = start_time - self.end_time: time = end_time + self.start_time: str = start_time.strftime("%H:%M") + self.end_time: str = end_time.strftime("%H:%M") self.message: str = message super().__init__(self.message) @@ -51,17 +53,18 @@ def zone_must_be_valid(cls, tz: str): timezone(tz) except UnknownTimeZoneError: raise ValueError("You should pass valid zone name") + return tz - def convert_to_utc(self, local_time: datetime) -> datetime: - local_dt = local_time.replace(tzinfo=timezone(self.tz)) - utc_dt = local_dt.astimezone(utc) + @staticmethod + def convert_to_utc(local_time: datetime) -> datetime: + utc_dt = local_time.astimezone(ZoneInfo("UTC")) return utc_dt @classmethod def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') - start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str)) - end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str)) + start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str), ZoneInfo(tz)) + end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str), ZoneInfo(tz)) if start_time >= end_time: raise InvalidIntervalException(start_time.time(), end_time.time()) @@ -81,10 +84,8 @@ def parse_time(time_str: str) -> time: raise InvalidTimeFormatException(time_str) def convert_to_timezone(self, new_tz: str): - start_dt = self.start_time.replace(tzinfo=timezone(self.tz)) - end_dt = self.end_time.replace(tzinfo=timezone(self.tz)) - new_start_dt = start_dt.astimezone(timezone(new_tz)) - new_end_dt = end_dt.astimezone(timezone(new_tz)) + new_start_dt = self.start_time.astimezone(ZoneInfo(new_tz)) + new_end_dt = self.end_time.astimezone(ZoneInfo(new_tz)) return Interval(start_time=new_start_dt, end_time=new_end_dt, tz=new_tz) def to_string(self): @@ -126,33 +127,67 @@ def merge_intervals(intervals): # Sort intervals by start time sorted_intervals = sorted(intervals, key=lambda x: x.start_time.time()) - merged_intervals = [sorted_intervals[0]] - for current in sorted_intervals[1:]: - last = merged_intervals[-1] - - if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): - # Merge intervals - merged_intervals[-1] = Interval( - start_time=min(last.start_time, current.start_time), - end_time=max(last.end_time, current.end_time), - tz=last.tz - ) + print("Debug:\n") + for i in sorted_intervals: + print(i.to_string(), i.start_time, i.end_time, i.tz, "\n") + + # Exclude repeating intervals + counter = Counter(sorted_intervals) + print("Debug:\n") + print(counter) + unique = [] + n_unique = [] + for interval in sorted_intervals: + if counter[interval] == 1: + unique.append(interval) else: - merged_intervals.append(current) + n_unique.append(interval) + + # Merge unique intervals + merged_intervals = [] + if len(unique) > 0: + merged_intervals.append(unique[0]) + for current in unique[1:]: + last = merged_intervals[-1] + + if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): + # Merge intervals + merged_intervals[-1] = Interval( + start_time=min(last.start_time, current.start_time), + end_time=max(last.end_time, current.end_time), + tz=last.tz + ) + else: + merged_intervals.append(current) + + return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time.time())) + - return merged_intervals +DEFAULT_INTERVAL = Interval.from_string("9:00 - 17:00", "Europe/Moscow") class DaySchedule(BaseModel): - name: str = "" - included: bool = True + name: str + included: bool = False intervals: List[Interval] = [] - def toggle_inclusion(self): + def toggle_inclusion(self) -> None: self.included = not self.included + if self.included and len(self.intervals) == 0: + self.add_interval(DEFAULT_INTERVAL) - def add_interval(self, interval: Interval): + def add_interval(self, interval: Interval) -> None: self.intervals.append(interval) + self.intervals = Interval.merge_intervals(self.intervals) - def remove_interval(self, interval: Interval): + def remove_interval(self, interval: Interval) -> None: self.intervals.remove(interval) + if len(self.intervals) == 0 and self.included: + self.toggle_inclusion() + + @staticmethod + def is_workday(day: str) -> bool: + return day not in {"Saturday", "Sunday"} + + def __hash__(self): + return hash(self.name) From 626729526cdc8837a58d2508ca2be16e0f7da879 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:08:55 +0300 Subject: [PATCH 26/97] (feat) add file for storing custom FSM states --- bot/fsm_states.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 bot/fsm_states.py diff --git a/bot/fsm_states.py b/bot/fsm_states.py new file mode 100644 index 0000000..e69de29 From 7925843139cf19b3e6a9acc55d2146a843c38efd Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:11:33 +0300 Subject: [PATCH 27/97] feat: change default callback separator symbol from ':' to '|' to be able to work with intervals --- bot/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/callbacks.py b/bot/callbacks.py index f3b2364..4cf7b28 100644 --- a/bot/callbacks.py +++ b/bot/callbacks.py @@ -1,7 +1,7 @@ from aiogram.filters.callback_data import CallbackData -class IntervalCallback(CallbackData, prefix="interval"): +class IntervalCallback(CallbackData, prefix="interval", sep="|"): weekday: str interval: str action: str # add, remove, edit From 9dfdfd35469178eb1428b610e267e7627e6de657 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:12:51 +0300 Subject: [PATCH 28/97] feat: edit empty schedule to be dictionary instead of list --- bot/constants.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 39aabb2..9fb7b86 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,5 +1,6 @@ -from aiogram import html -from bot.intervals import DaySchedule +from datetime import datetime + +from .intervals import DaySchedule ENCODING = "utf-8" @@ -36,9 +37,6 @@ class AppCommands: days_array = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] -empty_day_schedule = [DaySchedule() for _ in range(len(days_array))] - -for i in range(len(days_array)): - empty_day_schedule[i].name = days_array[i] +empty_schedule = {day: DaySchedule(name=day) for day in days_array} -default_user_schedule = empty_day_schedule +default_user_schedule = empty_schedule From adf6875a26a5aab87ec38d83456ea5a42f61ae63 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:14:03 +0300 Subject: [PATCH 29/97] feat: edit mongodb config to return datetime in offset-aware state --- bot/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/db.py b/bot/db.py index aa6be59..8d6e893 100644 --- a/bot/db.py +++ b/bot/db.py @@ -10,7 +10,8 @@ async def main(settings: Settings): client = AsyncIOMotorClient( # TODO enable user and password # f"mongodb://{settings.mongo_username}:{settings.mongo_password}@{settings.mongo_host}:{settings.mongo_port}" - f"mongodb://{settings.mongo_host}:{settings.mongo_port}" + f"mongodb://{settings.mongo_host}:{settings.mongo_port}", + tz_aware=True ) db = client["bot_states"] From 1489a852969eaa7132dd93250daf5b9e2b63e35c Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:14:51 +0300 Subject: [PATCH 30/97] feat: edit filters to work with callbacks --- bot/filters.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/filters.py b/bot/filters.py index 9f32c35..8b804c6 100644 --- a/bot/filters.py +++ b/bot/filters.py @@ -1,5 +1,5 @@ from aiogram.filters import Filter -from aiogram.types import Message, User +from aiogram.types import Message, CallbackQuery, User from .state import load_state @@ -13,8 +13,8 @@ async def __call__(self, message: Message): class HasMessageUserUsername(Filter): - async def __call__(self, message: Message): - match user := message.from_user: + async def __call__(self, obj: Message | CallbackQuery): + match user := obj.from_user: case User(): match user.username: case str(): @@ -23,9 +23,14 @@ async def __call__(self, message: Message): class HasChatState(Filter): - async def __call__(self, message: Message): - chat_state = await load_state(message.chat.id, message.message_thread_id) - return {"chat_state": chat_state} + async def __call__(self, obj: Message | CallbackQuery): + match obj: + case Message(): + chat_state = await load_state(obj.chat.id, obj.message_thread_id) + return {"chat_state": chat_state} + case CallbackQuery(): + chat_state = await load_state(obj.message.chat.id, obj.message.message_thread_id) + return {"chat_state": chat_state} class IsReplyToMeetingMessage(Filter): From 43ac0366d468ab81f2ace99e255544f9e1807049 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:15:38 +0300 Subject: [PATCH 31/97] feat: add group of states for interval editing --- bot/fsm_states.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/fsm_states.py b/bot/fsm_states.py index e69de29..11ca2b9 100644 --- a/bot/fsm_states.py +++ b/bot/fsm_states.py @@ -0,0 +1,5 @@ +from aiogram.filters.state import StatesGroup, State + + +class IntervalEditingState(StatesGroup): + EnterNewInterval = State() From a60a445f1854cfd59816411b3b63ceba5a0cb122 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:16:50 +0300 Subject: [PATCH 32/97] feat: edit interval model to store times in UTC --- bot/intervals.py | 74 ++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 520057c..007fb27 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,9 +1,9 @@ -from typing import List +from typing import List, Tuple from collections import Counter from datetime import datetime, time from zoneinfo import ZoneInfo -from pydantic import BaseModel, computed_field, field_validator +from pydantic import BaseModel, field_validator from pytz import timezone, UnknownTimeZoneError @@ -33,20 +33,10 @@ def __init__(self, start_time: time, end_time: time, message: str = "Start time class Interval(BaseModel): - start_time: datetime - end_time: datetime + start_time_utc: datetime + end_time_utc: datetime tz: str = "UTC" - @computed_field - @property - def start_time_utc(self) -> datetime: - return self.convert_to_utc(self.start_time) - - @computed_field - @property - def end_time_utc(self) -> datetime: - return self.convert_to_utc(self.end_time) - @field_validator("tz") def zone_must_be_valid(cls, tz: str): try: @@ -55,11 +45,6 @@ def zone_must_be_valid(cls, tz: str): raise ValueError("You should pass valid zone name") return tz - @staticmethod - def convert_to_utc(local_time: datetime) -> datetime: - utc_dt = local_time.astimezone(ZoneInfo("UTC")) - return utc_dt - @classmethod def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') @@ -69,7 +54,15 @@ def from_string(cls, interval_str: str, tz: str = "UTC"): if start_time >= end_time: raise InvalidIntervalException(start_time.time(), end_time.time()) - return cls(start_time=start_time, end_time=end_time, tz=tz) + start_time_utc = cls.convert_to_utc(start_time) + end_time_utc = cls.convert_to_utc(end_time) + + return cls(start_time_utc=start_time_utc, end_time_utc=end_time_utc, tz=tz) + + @staticmethod + def convert_to_utc(local_time: datetime) -> datetime: + utc_dt = local_time.astimezone(ZoneInfo("UTC")) + return utc_dt @staticmethod def parse_time(time_str: str) -> time: @@ -83,13 +76,19 @@ def parse_time(time_str: str) -> time: except ValueError: raise InvalidTimeFormatException(time_str) - def convert_to_timezone(self, new_tz: str): - new_start_dt = self.start_time.astimezone(ZoneInfo(new_tz)) - new_end_dt = self.end_time.astimezone(ZoneInfo(new_tz)) - return Interval(start_time=new_start_dt, end_time=new_end_dt, tz=new_tz) + def convert_to_timezone(self, new_tz: str) -> Tuple[datetime, datetime]: + new_start_dt = self.start_time_utc.astimezone(ZoneInfo(new_tz)) + new_end_dt = self.end_time_utc.astimezone(ZoneInfo(new_tz)) + return new_start_dt, new_end_dt + + def to_string(self, tz: str = "UTC"): + if tz == "UTC": + start_time = self.start_time_utc + end_time = self.end_time_utc + else: + start_time, end_time = self.convert_to_timezone(new_tz=tz) - def to_string(self): - return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" + return f"{start_time.strftime('%H:%M')} - {end_time.strftime('%H:%M')}" def __hash__(self): return hash((self.start_time_utc, self.end_time_utc)) @@ -125,16 +124,10 @@ def merge_intervals(intervals): return [] # Sort intervals by start time - sorted_intervals = sorted(intervals, key=lambda x: x.start_time.time()) - - print("Debug:\n") - for i in sorted_intervals: - print(i.to_string(), i.start_time, i.end_time, i.tz, "\n") + sorted_intervals = sorted(intervals, key=lambda x: x.start_time_utc.time()) # Exclude repeating intervals counter = Counter(sorted_intervals) - print("Debug:\n") - print(counter) unique = [] n_unique = [] for interval in sorted_intervals: @@ -150,17 +143,17 @@ def merge_intervals(intervals): for current in unique[1:]: last = merged_intervals[-1] - if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): + if current.overlaps_with(last) or current.start_time_utc.time() <= last.end_time_utc.time(): # Merge intervals merged_intervals[-1] = Interval( - start_time=min(last.start_time, current.start_time), - end_time=max(last.end_time, current.end_time), + start_time_utc=min(last.start_time_utc, current.start_time_utc), + end_time_utc=max(last.end_time_utc, current.end_time_utc), tz=last.tz ) else: merged_intervals.append(current) - return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time.time())) + return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time_utc.time())) DEFAULT_INTERVAL = Interval.from_string("9:00 - 17:00", "Europe/Moscow") @@ -180,10 +173,11 @@ def add_interval(self, interval: Interval) -> None: self.intervals.append(interval) self.intervals = Interval.merge_intervals(self.intervals) - def remove_interval(self, interval: Interval) -> None: + def remove_interval(self, interval: Interval, ignore_inclusion=False) -> None: self.intervals.remove(interval) - if len(self.intervals) == 0 and self.included: - self.toggle_inclusion() + if not ignore_inclusion: + if len(self.intervals) == 0 and self.included: + self.toggle_inclusion() @staticmethod def is_workday(day: str) -> bool: From dfb5a15b3a1683b1c3da2c7b3511e6c89da44ca3 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:18:06 +0300 Subject: [PATCH 33/97] feat: modify keyboards for new callbacks and intervals --- bot/keyboards.py | 56 +++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/bot/keyboards.py b/bot/keyboards.py index a8fcaca..44e925a 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -1,6 +1,10 @@ +from typing import Dict + from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton from .callbacks import IntervalCallback, WeekdayCallback +from .intervals import Interval, DaySchedule, DEFAULT_INTERVAL +from .constants import days_array INCLUDED_1 = "โœ…" INCLUDED_2 = "๐Ÿ—น" @@ -12,55 +16,53 @@ REMOVE = "โœ–๏ธ" -def get_interval_keyboard(interval: str, weekday: str) -> InlineKeyboardBuilder: +def get_interval_keyboard(interval: Interval, weekday: str, tz: str) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() - interval_std = interval.replace(":", "|") + interval_str = interval.to_string(tz=tz) + default_interval_str = DEFAULT_INTERVAL.to_string(tz=tz) - builder.button(text=interval, switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}") - builder.button(text=REMOVE, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='remove')) - builder.button(text=ADD, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='add')) + builder.button( + text=interval_str, + callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='edit') + ) + builder.button( + text=REMOVE, + callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='remove') + ) + builder.button( + text=ADD, + callback_data=IntervalCallback(weekday=weekday, interval=default_interval_str, action='add') + ) builder.adjust(3) return builder -def get_weekday_keyboard(weekday: str, content: dict) -> InlineKeyboardBuilder: +def get_weekday_keyboard(weekday: DaySchedule, tz: str) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() - included_text = INCLUDED_3 if content["include"] else NOT_INCLUDED_3 + included_text = INCLUDED_3 if weekday.included else NOT_INCLUDED_3 - day = InlineKeyboardButton(text=f"{weekday}", callback_data="#") + day = InlineKeyboardButton(text=f"{weekday.name}", callback_data="#") status = InlineKeyboardButton( text=included_text, - callback_data=WeekdayCallback(weekday=weekday, action="toggle").pack() + callback_data=WeekdayCallback(weekday=weekday.name, action="toggle").pack() ) builder.row(day, status) - if content["include"]: - for interval in content["intervals"]: - interval_builder = get_interval_keyboard(interval, weekday) + if weekday.included: + for interval in weekday.intervals: + interval_builder = get_interval_keyboard(interval, weekday.name, tz) builder.attach(interval_builder) return builder -def get_schedule_keyboard(week_schedule: dict) -> InlineKeyboardMarkup: +def get_schedule_keyboard(week_schedule: Dict[str, DaySchedule], tz: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - for weekday, content in week_schedule.items(): - weekday_builder = get_weekday_keyboard(weekday, content) + for weekday in days_array: + weekday_builder = get_weekday_keyboard(week_schedule[weekday], tz) builder.attach(weekday_builder) return builder.as_markup() - - -def get_interval_error_keyboard(interval: str, weekday: str) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - - builder.button(text="Cancel", callback_data="cancel_editing_interval") - builder.button( - text="Enter the interval again", - switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}" - ) - - return builder.adjust(2).as_markup() From 4ea8bd8661eb47d4427aeb900a93816017b450e8 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:19:16 +0300 Subject: [PATCH 34/97] feat: add fields to chat user document to handle interval editing --- bot/state.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/state.py b/bot/state.py index 2d10aff..d1dd441 100644 --- a/bot/state.py +++ b/bot/state.py @@ -14,13 +14,19 @@ class ChatUser(BaseModel): username: str = "" is_joined: bool = False - schedule: List[DaySchedule] = default_user_schedule + schedule: Dict[str, DaySchedule] = default_user_schedule personal_default_working_time: Optional[Interval] = None time_zone_shift: int = 0 meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday reminder_period: Optional[int] = None non_replied_daily_msgs: set[int] = set(range(0, 3)) + # TODO: relocate these fields to cache (Redis for example) + schedule_msg: Optional[int] = None + to_delete_msg_ids: set[int] = set() + to_edit_weekday: Optional[str] = None + to_edit_interval: Optional[str] = None + def __hash__(self): return hash(self.username) From 8020a296ae0159d26ae839f3f57b1446539d97ff Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:20:18 +0300 Subject: [PATCH 35/97] feat: edit error messages to more use friendly form --- bot/messages.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/messages.py b/bot/messages.py index de8e84c..3be1a6d 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -60,20 +60,22 @@ def make_daily_messages(usernames: str) -> List[str]: def make_interval_validation_message(interval_str: str, tz: str) -> Tuple[bool, str]: + new = "Please, reenter interval." try: interval = Interval.from_string(interval_str=interval_str, tz=tz) - msg = _("Successfully parsed interval: {interval}").format(interval=interval) + msg = _("Successfully parsed interval: {interval}\n{new}").format(interval=interval, new=new) return True, msg except InvalidTimeFormatException as e: - msg = _("Error: Invalid time format for '{time}'. {msg}").format(time=e.time_str, msg=e.message) + msg = _("Invalid time format for '{time}'. {msg}\n{new}").format(time=e.time_str, msg=e.message, new=new) return False, msg except InvalidIntervalException as e: - msg = _("Error: {msg} (start: {start}, end: {end})").format( + msg = _("{msg} (start: {start}, end: {end})\n{new}").format( msg=e.message, start=e.start_time, - end=e.end_time + end=e.end_time, + new=new ) return False, msg except Exception as e: - msg = _("Error: An unexpected error occurred. {error}").format(error=str(e)) + msg = _("An unexpected error occurred. {error}").format(error=str(e)) return False, msg From ac88eeb2bddcc4fc5b8accd925fb0fb9d28bec80 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:21:39 +0300 Subject: [PATCH 36/97] feat: connect al operations with mongodb and rework interval editng --- bot/work_time.py | 369 +++++++++++++++++++---------------------------- 1 file changed, 149 insertions(+), 220 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index f9629b6..d1a0d74 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -1,10 +1,11 @@ import re -from textwrap import dedent -from datetime import datetime +import asyncio from aiogram import Bot, Router, F from aiogram.filters.command import Command -from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent +from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.utils import markdown as fmt from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -12,255 +13,183 @@ from .custom_types import SendMessage from .messages import make_interval_validation_message from .callbacks import IntervalCallback, WeekdayCallback -from .constants import interval_format, sample_interval +from .fsm_states import IntervalEditingState from .intervals import Interval -from .filters import HasChatState, HasMessageUserUsername, HasMessageText -from .keyboards import get_schedule_keyboard, get_interval_error_keyboard -from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm - - -EDIT_HANDLE_IVL = \ - r'^edit\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\s*-->\s*\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' - -EDIT_PARSE_IVL = \ - r'^edit\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})\s*-->' \ - r'\s*(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' - -ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' -ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' - -schedule_db = { - "Monday": { - "include": True, - "intervals": ["9:00 - 18:00"] - }, - "Tuesday": { - "include": False, - "intervals": [] - }, - "Wednesday": { - "include": True, - "intervals": ["10:00 - 17:00"] - }, - "Thursday": { - "include": False, - "intervals": [] - }, - "Friday": { - "include": True, - "intervals": ["9:00 - 18:00"] - }, - "Saturday": { - "include": False, - "intervals": [] - }, - "Sunday": { - "include": False, - "intervals": [] - } - } - -chat_db = { - "default_working_time": "9:00 - 17:00", - "personal_default_working_time": "8:00 - 18:00", - "schedule_msg": None, - "tz": "Europe/Moscow", - "shift": 0, - "edit_interval_message": None, - "message_with_keyboard": None -} +from .filters import HasChatState, HasMessageUserUsername +from .keyboards import get_schedule_keyboard +from .state import ChatState, save_state, get_user, load_user_pm + + +INTERVAL_PATTERN = r"\b\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\b" + +# TODO: Apply tz validation in Interval class +# TODO: Handle two scenarios for timezone changing via shift and write logic for intervals for those cases +# TODO: Check code and messages by scenario: https://github.com/team-work-tools/team-work-telegram-bot/pull/106 +# TODO: Write logic for setting group schedule: https://t.me/c/2215513034/6/2076, https://t.me/c/2215513034/6/2083 def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message(Command(bot_command_names.set_default_working_time), HasMessageText(), HasMessageUserUsername()) - async def set_default_working_time(message: Message, message_text: str): - cmd = "/" + bot_command_names.set_default_working_time - interval = message_text.replace(cmd, "").strip() - - is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") - if is_valid: - chat_db["default_working_time"] = interval - await message.answer( - _( - "Your group default working time is set to {interval}" - ).format(interval=interval) - ) - else: - await message.answer( - dedent( - _( - f""" - Please write group default working time in {interval_format} format. - - Example: - - /{set_default_working_time} {sample_interval} - """ - ).format( - interval_format=interval_format, - set_default_working_time=bot_command_names.set_default_working_time, - sample_interval=sample_interval - ) - ) - ) - - @router.message(Command(bot_command_names.set_personal_default_working_time), HasMessageText()) - async def set_personal_default_working_time(message: Message, message_text: str): - cmd = "/" + bot_command_names.set_personal_default_working_time - interval = message_text.replace(cmd, "").strip() - - is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") - if is_valid: - chat_db["personal_default_working_time"] = interval - await message.answer( - _( - "Your personal default working time is set to {interval}" - ).format(interval=interval) - ) - else: - await message.answer( - dedent( - _( - f""" - Please write your personal default working time in {interval_format} format. - - Example: - - /{set_personal_default_working_time} {sample_interval} - """ - ).format( - interval_format=interval_format, - set_default_working_time=bot_command_names.set_personal_default_working_time, - sample_interval=sample_interval - ) - ) - ) - @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) async def show_user_schedule(message: Message, username: str, chat_state: ChatState): - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - - if personal_default_interval is not None or group_default_interval is not None: - default = personal_default_interval if personal_default_interval is not None else group_default_interval - - for weekday in schedule_db: - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(default) - - layout = get_schedule_keyboard(schedule_db) - schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) - chat_db["schedule_msg"] = schedule_msg - else: - await message.answer("You should set up default group or personal working time first!") - - @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) - async def show_inline_interval_editing(inline_query: InlineQuery): - message_with_keyboard = chat_db["message_with_keyboard"] - edit_interval_message = chat_db["edit_interval_message"] - - if message_with_keyboard is not None and edit_interval_message is not None: - await message_with_keyboard.delete() - await edit_interval_message.delete() - - suggestion = InlineQueryResultArticle( - id=inline_query.query, - title=inline_query.query, - input_message_content=InputTextMessageContent( - message_text=inline_query.query - ) - ) - await inline_query.answer([suggestion], is_personal=True) - - @router.message(F.text.regexp(EDIT_HANDLE_IVL), HasMessageUserUsername(), HasChatState()) - async def handle_interval_editing(message: Message, username: str, chat_state: ChatState): - - parse_pattern = re.compile(EDIT_PARSE_IVL) - parse_match = parse_pattern.match(message.text) - if parse_match: - weekday = parse_match.group("weekday") - st_time_prev = parse_match.group("start_time1") - end_time_prev = parse_match.group("end_time1") - st_time_edit = parse_match.group("start_time2") - end_time_edit = parse_match.group("end_time2") - - interval_prev = f"{st_time_prev} - {end_time_prev}" - interval_edit = f"{st_time_edit} - {end_time_edit}" - - is_valid, status_msg = make_interval_validation_message(interval_str=interval_edit, tz=chat_db["tz"]) + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone + + layout = get_schedule_keyboard(week_schedule=week_schedule, tz=tz) + schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + + user.schedule_msg = schedule_msg.message_id + await save_state(chat_state) + + @router.callback_query(IntervalCallback.filter(F.action == "edit"), HasMessageUserUsername(), HasChatState()) + async def show_interval_editing_instruction( + cb: CallbackQuery, + state: FSMContext, + callback_data: IntervalCallback, + username: str, + chat_state: ChatState + ): + + weekday = callback_data.weekday + interval_str = callback_data.interval + + instruction_text = "Send me the new interval in the hh:mm - hh:mm format." + example_text = "Example: " + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), sep="") + + await state.set_state(IntervalEditingState.EnterNewInterval) + + await cb.answer() + edit_message = await cb.message.answer(text=fmt.text(text)) + + user = await get_user(chat_state, username) + user.to_edit_weekday = weekday + user.to_edit_interval = interval_str + user.to_delete_msg_ids.add(edit_message.message_id) + await save_state(chat_state) + + @router.message( + IntervalEditingState.EnterNewInterval, + HasMessageUserUsername(), + HasChatState() + ) + async def handle_interval_editing(message: Message, state: FSMContext, username: str, chat_state: ChatState): + + parse_pattern = re.compile(INTERVAL_PATTERN) + parse_match = re.findall(parse_pattern, message.text) + + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + tz = user_pm.personal_time_zone + + user.to_delete_msg_ids.add(message.message_id) + + # User entered two "valid" intervals + if len(parse_match) > 1: + msg = await message.answer("Please, enter only one interval.") + user.to_delete_msg_ids.add(msg.message_id) + + # User entered one "valid" interval + if len(parse_match) == 1: + new_interval = parse_match[0] + is_valid, status_msg = make_interval_validation_message(interval_str=new_interval, tz=tz) + + # Interval is valid if is_valid: + old_interval_obj = Interval.from_string(user.to_edit_interval, tz) + new_interval_obj = Interval.from_string(new_interval, tz) + user.schedule[user.to_edit_weekday].remove_interval(old_interval_obj, ignore_inclusion=True) + user.schedule[user.to_edit_weekday].add_interval(new_interval_obj) + + layout = get_schedule_keyboard(user.schedule, tz) + await bot.edit_message_reply_markup( + chat_id=chat_state.chat_id, + message_id=user.schedule_msg, + reply_markup=layout + ) - intervals = schedule_db[weekday]["intervals"] - if interval_prev in intervals: - intervals.remove(interval_prev) - intervals.append(interval_edit) - schedule_db[weekday]["intervals"] = intervals + success_msg = await message.answer("You successfully edited interval!") + await asyncio.sleep(1) + await success_msg.delete() + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) - layout = get_schedule_keyboard(schedule_db) - if chat_db["schedule_msg"]: - old_msg = chat_db["schedule_msg"] - await old_msg.edit_reply_markup(reply_markup=layout) + user.to_delete_msg_ids = set() - await message.delete() + await state.clear() + # Interval is not valid else: - layout = get_interval_error_keyboard(interval_prev, weekday) - keyboard_msg = await message.reply(text=status_msg, reply_markup=layout) + msg = await message.answer(status_msg) + user.to_delete_msg_ids.add(msg.message_id) + + # Text entered by user is not interval + if len(parse_match) == 0: + instruction_text = "Please send me the valid interval in the hh:mm - hh:mm format." + example_text = "Example: " + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), sep="") - chat_db["edit_interval_message"] = message - chat_db["message_with_keyboard"] = keyboard_msg + msg = await message.answer(fmt.text(text)) + user.to_delete_msg_ids.add(msg.message_id) - @router.callback_query(IntervalCallback.filter(F.action == 'add')) - async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): + await save_state(chat_state) - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - default = personal_default_interval if personal_default_interval is not None else group_default_interval + @router.callback_query(IntervalCallback.filter(F.action == 'add'), HasMessageUserUsername(), HasChatState()) + async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - schedule_db[weekday]["intervals"].append(default) + interval_str = callback_data.interval + + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone + + interval = Interval.from_string(interval_str, tz) + week_schedule[weekday].add_interval(interval) - layout = get_schedule_keyboard(schedule_db) + layout = get_schedule_keyboard(week_schedule, tz) await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) - @router.callback_query(IntervalCallback.filter(F.action == 'remove')) - async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - default = personal_default_interval if personal_default_interval is not None else group_default_interval + @router.callback_query(IntervalCallback.filter(F.action == 'remove'), HasMessageUserUsername(), HasChatState()) + async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - interval = callback_data.interval.replace("|", ":") - schedule_db[weekday]["intervals"].remove(interval) + interval_str = callback_data.interval - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(default) + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone - layout = get_schedule_keyboard(schedule_db) + interval = Interval.from_string(interval_str, tz) + week_schedule[weekday].remove_interval(interval) + + layout = get_schedule_keyboard(week_schedule, tz) await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) - @router.callback_query(WeekdayCallback.filter(F.action == 'toggle')) - async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - default = personal_default_interval if personal_default_interval is not None else group_default_interval + @router.callback_query(WeekdayCallback.filter(F.action == 'toggle'), HasMessageUserUsername(), HasChatState()) + async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(default) + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone - layout = get_schedule_keyboard(schedule_db) - await cb.message.edit_reply_markup(reply_markup=layout) + week_schedule[weekday].toggle_inclusion() - @router.callback_query(F.data == "cancel_editing_interval") - async def cancel_editing_interval(cb: CallbackQuery): - message_with_keyboard = cb.message - edit_interval_message = cb.message.reply_to_message + layout = get_schedule_keyboard(week_schedule, tz) + await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) - await message_with_keyboard.delete() - await edit_interval_message.delete() + @router.callback_query(F.data == "#") + async def handle_placeholders(cb: CallbackQuery): + await cb.answer() From a9d272b7d23d5325083221dc50e22250d13cc881 Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sun, 7 Jul 2024 19:41:07 +0300 Subject: [PATCH 37/97] test(unit): added tests for Interaval class --- tests/test_non_handlers/test_intervals.py | 128 ++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 tests/test_non_handlers/test_intervals.py diff --git a/tests/test_non_handlers/test_intervals.py b/tests/test_non_handlers/test_intervals.py new file mode 100644 index 0000000..4e19b91 --- /dev/null +++ b/tests/test_non_handlers/test_intervals.py @@ -0,0 +1,128 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +import pytest + +from bot.intervals import Interval, IMMUTABLE_DATE, InvalidIntervalException, InvalidTimeFormatException + + +def test_zone_must_be_valid(): + """ + Check correctness of the validation of the time zone. + """ + a = Interval.zone_must_be_valid("Us/eastern") + assert a == "Us/eastern" + + a = Interval.zone_must_be_valid("Europe/Moscow") + assert a == "Europe/Moscow" + + with pytest.raises(ValueError): + b = Interval.zone_must_be_valid("somezone") + + +def test_from_string(): + """ + Checks correctness of the creation of an interval. + """ + tz_1 = "UTC" + tz_2 = "Europe/Moscow" + time_start_1 = datetime.strptime("1:00", "%H:%M").time() + time_end_1 = datetime.strptime("11:20", "%H:%M").time() + + datetime_start_1 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_1)) + datetime_end_1 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_1)) + + datetime_start_2 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_2)) + datetime_end_2 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_2)) + + a = Interval.from_string("1:00 - 11:20", tz_1) + assert a.start_time_utc == datetime_start_1 + assert a.end_time_utc == datetime_end_1 + assert a.tz == tz_1 + + # Start time should be less than end one + with pytest.raises(InvalidIntervalException): + a = Interval.from_string("11:20 - 1:00", tz_1) + + # Skip time zone + a = Interval.from_string("1:00 - 11:20") + assert a.start_time_utc == datetime_start_1 + assert a.end_time_utc == datetime_end_1 + assert a.tz == tz_1 + + # Other time zone + a = Interval.from_string("1:00 - 11:20", tz_2) + assert a.start_time_utc == datetime_start_2 + assert a.end_time_utc == datetime_end_2 + assert a.tz == tz_2 + + # Other time format + a = Interval.from_string("1.00 - 11.20", tz_2) + assert a.start_time_utc == datetime_start_2 + assert a.end_time_utc == datetime_end_2 + assert a.tz == tz_2 + + +def test_convert_to_utc(): + tz_1 = "Europe/Moscow" + time_start_1 = datetime.strptime("1:00", "%H:%M").time() + datetime_1 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_1)) + + assert datetime_1.astimezone(ZoneInfo("UTC")) == Interval.convert_to_utc(datetime_1) + + +def test_parse_time(): + time_1 = "01:00" + time_2 = "23.12" + wrong_time = "24:99" + + assert datetime.strptime(time_1, "%H:%M").time() == Interval.parse_time(time_1) + assert datetime.strptime(time_2, "%H.%M").time() == Interval.parse_time(time_2) + + with pytest.raises(InvalidTimeFormatException): + Interval.parse_time(wrong_time) + + +def test_overlaps_with(): + interval_1 = Interval.from_string("1:00 - 4:00", tz="UTC") + # Should overlap with 1 + interval_2 = Interval.from_string("4:00 - 5:00", tz="UTC") + interval_3 = Interval.from_string("3:00 - 5:00", tz="UTC") + interval_4 = Interval.from_string("2:00 - 3:00", tz="UTC") + + # Should not overlap with 1 + interval_5 = Interval.from_string("6:00 - 7:00", tz="UTC") + + test_func = Interval.overlaps_with + assert test_func(interval_1, interval_1) is True + assert test_func(interval_1, interval_2) is False + assert test_func(interval_1, interval_3) is True + assert test_func(interval_1, interval_4) is True + assert test_func(interval_1, interval_5) is False + + +def test_merge_intervals(): + # TODO several same intervals + interval_1 = Interval.from_string("1:00 - 4:00", tz="UTC") + interval_2 = Interval.from_string("1:20 - 4:00", tz="UTC") + interval_3 = Interval.from_string("2:00 - 3:00", tz="UTC") + interval_4 = Interval.from_string("4:00 - 5:00", tz="UTC") + interval_5 = Interval.from_string("7:00 - 8:00", tz="UTC") + + interval_6 = Interval.from_string("1:00 - 5:00", tz="UTC") + + interval_7 = Interval.from_string("2:00 - 3:00", tz="Europe/Moscow") + + arr = [interval_1, interval_2, interval_3, interval_4, interval_5] + merged = [interval_6, interval_5] + + assert Interval.merge_intervals(arr) == merged + + # Different time zones + with pytest.raises(ValueError): + Interval.merge_intervals(arr + [interval_7]) + + # Empty + # TODO output should be empty + with pytest.raises(ValueError): + Interval.merge_intervals([]) From bd6cced6cf45ffb90793c12588546fb85cbe6a2b Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 18:26:23 +0300 Subject: [PATCH 38/97] feat(CI): add file for CI settings in Github Actions --- .github/workflows/ci-team-34.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .github/workflows/ci-team-34.yaml diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml new file mode 100644 index 0000000..e69de29 From f1397afa41010ee97c57a78551d87f04b5b8fac1 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 18:32:43 +0300 Subject: [PATCH 39/97] refactor(linter): Fix linter errors --- .github/workflows/ci-team-34.yaml | 64 +++++++++++++++++++++++++++++++ bot/bot.py | 7 ---- bot/commands.py | 2 - bot/constants.py | 2 - bot/custom_types.py | 2 - bot/handlers.py | 4 +- bot/language.py | 3 -- bot/messages.py | 4 -- bot/reminder.py | 2 +- 9 files changed, 67 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml index e69de29..70f7b33 100644 --- a/.github/workflows/ci-team-34.yaml +++ b/.github/workflows/ci-team-34.yaml @@ -0,0 +1,64 @@ +name: Team 34 CI Pipeline + +on: [push, pull_request] + +env: + POETRY_VERSION: "1.8.3" + DEFAULT_PY_VERSION: "3.11" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ env.DEFAULT_PY_VERSION }} + + - name: Linting + env: + RUFF_OUTPUT_FORMAT: github + run: | + pip install ruff + ruff check bot + + test-with-coverage: + needs: lint + runs-on: ubuntu-latest + steps: + + - name: Checkout code + uses: actions/checkout@v2 + + - name: Install Poetry + shell: bash + run: pipx install poetry==${{ env.POETRY_VERSION }} + + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: ${{ env.DEFAULT_PY_VERSION }} + + - name: Install pytest and coverage + run: | + python -m pip install --upgrade pip + python3 -m pip install coverage + pip install -U pytest + + - name: Install dependencies + run: poetry install + + - name: Run tests with coverage + run: | + coverage run -m pytest tests + coverage report + + - name: Upload coverage report + uses: actions/upload-artifact@v2 + with: + name: coverage-report + path: coverage.xml diff --git a/bot/bot.py b/bot/bot.py index e5ad775..997f8c8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -10,7 +10,6 @@ from pytz import utc from . import db, handlers -from .commands import BotCommands from .constants import jobstore from .custom_types import ChatId, SendMessage from .meeting import schedule_meeting @@ -41,12 +40,6 @@ async def restore_scheduled_jobs( ) -async def on_startup(): - bot_commands = [ - BotCommands() - ] - - async def main(settings: Settings) -> None: await db.main(settings=settings) diff --git a/bot/commands.py b/bot/commands.py index 654a925..3715ef4 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -1,8 +1,6 @@ from aiogram.utils.i18n import gettext as _ from pydantic import BaseModel -from .language import Language - class BotCommands(BaseModel): # global commands diff --git a/bot/constants.py b/bot/constants.py index 9fb7b86..742c160 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,5 +1,3 @@ -from datetime import datetime - from .intervals import DaySchedule ENCODING = "utf-8" diff --git a/bot/custom_types.py b/bot/custom_types.py index 753fee9..5ccc82d 100644 --- a/bot/custom_types.py +++ b/bot/custom_types.py @@ -1,9 +1,7 @@ from typing import Protocol, Optional -from aiogram import Bot from aiogram.types import Message -from . import state from .chat import ChatId from .state import ChatState diff --git a/bot/handlers.py b/bot/handlers.py index 85b9a23..25ee242 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -131,7 +131,7 @@ async def set_meetings_time( start_date=html.bold(meeting_time.strftime("%Y-%m-%d")), ) ) - except Exception as e: + except Exception: await message.reply( dedent( _( @@ -251,7 +251,7 @@ async def set_personal_meetings_days( meeting_days=html.bold(", ".join(day_tokens)) ) ) - except Exception as e: + except Exception: await message.reply( dedent( _( diff --git a/bot/language.py b/bot/language.py index 09498eb..81ac71e 100644 --- a/bot/language.py +++ b/bot/language.py @@ -1,8 +1,5 @@ -from dataclasses import dataclass from enum import Enum -from pydantic import BaseModel - class Language(Enum): ru = "ru" diff --git a/bot/messages.py b/bot/messages.py index 3be1a6d..4d0e27c 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -1,5 +1,3 @@ -from dataclasses import dataclass -from datetime import datetime from textwrap import dedent from typing import List, Tuple @@ -7,8 +5,6 @@ from aiogram.utils.i18n import gettext as _ from .commands import bot_command_descriptions, bot_command_names -from .constants import day_of_week_pretty -from .language import Language from .intervals import Interval, InvalidTimeFormatException, InvalidIntervalException diff --git a/bot/reminder.py b/bot/reminder.py index 6c90f07..e4638b3 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -9,7 +9,7 @@ from apscheduler.triggers.interval import IntervalTrigger from .messages import make_daily_messages -from .constants import day_of_week, jobstore +from .constants import jobstore from .custom_types import ChatId, SendMessage from .state import load_state, load_user_pm, get_user, ChatState from textwrap import dedent From ab8320ec8f8aa07328bc296334d6958398581d2e Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 18:41:04 +0300 Subject: [PATCH 40/97] fix(CI): add dependencies for testing in yaml file --- .github/workflows/ci-team-34.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml index 70f7b33..486265c 100644 --- a/.github/workflows/ci-team-34.yaml +++ b/.github/workflows/ci-team-34.yaml @@ -47,7 +47,7 @@ jobs: run: | python -m pip install --upgrade pip python3 -m pip install coverage - pip install -U pytest + pip install -U pytest, pytest_asyncio, mongomock_motor - name: Install dependencies run: poetry install From 8beb5777235e4cef3e9a1a9a4f5732fc906e0c8f Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 18:43:49 +0300 Subject: [PATCH 41/97] fix(CI): add dependencies for testing in CI --- .github/workflows/ci-team-34.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml index 486265c..a18c674 100644 --- a/.github/workflows/ci-team-34.yaml +++ b/.github/workflows/ci-team-34.yaml @@ -47,7 +47,7 @@ jobs: run: | python -m pip install --upgrade pip python3 -m pip install coverage - pip install -U pytest, pytest_asyncio, mongomock_motor + pip install -U pytest pytest-asyncio mongomock[motor] - name: Install dependencies run: poetry install From a686b333d103a2b6eaa61026574ae445e8038d90 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 18:45:52 +0300 Subject: [PATCH 42/97] fix(CI): add motor dependency for testing in CI --- .github/workflows/ci-team-34.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml index a18c674..7b45c0b 100644 --- a/.github/workflows/ci-team-34.yaml +++ b/.github/workflows/ci-team-34.yaml @@ -47,7 +47,7 @@ jobs: run: | python -m pip install --upgrade pip python3 -m pip install coverage - pip install -U pytest pytest-asyncio mongomock[motor] + pip install -U pytest pytest-asyncio mongomock-motor - name: Install dependencies run: poetry install From 475a0d5558754ab2ae3d6e96d78b10c4de086160 Mon Sep 17 00:00:00 2001 From: examplefirstaccount <68125069+examplefirstaccount@users.noreply.github.com> Date: Sun, 7 Jul 2024 18:48:23 +0300 Subject: [PATCH 43/97] feat(CI): update ci-team-34.yaml --- .github/workflows/ci-team-34.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml index 7b45c0b..fdc8ef5 100644 --- a/.github/workflows/ci-team-34.yaml +++ b/.github/workflows/ci-team-34.yaml @@ -47,7 +47,7 @@ jobs: run: | python -m pip install --upgrade pip python3 -m pip install coverage - pip install -U pytest pytest-asyncio mongomock-motor + pip install -U pytest pytest-asyncio pymongo mongomock-motor - name: Install dependencies run: poetry install From 8862c54ce477d1bf7505f83b01c888d605e3fdb2 Mon Sep 17 00:00:00 2001 From: examplefirstaccount <68125069+examplefirstaccount@users.noreply.github.com> Date: Sun, 7 Jul 2024 19:16:22 +0300 Subject: [PATCH 44/97] feat(CI): update ci-team-34.yaml --- .github/workflows/ci-team-34.yaml | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-team-34.yaml b/.github/workflows/ci-team-34.yaml index fdc8ef5..004fa2d 100644 --- a/.github/workflows/ci-team-34.yaml +++ b/.github/workflows/ci-team-34.yaml @@ -43,19 +43,13 @@ jobs: with: python-version: ${{ env.DEFAULT_PY_VERSION }} - - name: Install pytest and coverage - run: | - python -m pip install --upgrade pip - python3 -m pip install coverage - pip install -U pytest pytest-asyncio pymongo mongomock-motor - - name: Install dependencies run: poetry install - name: Run tests with coverage run: | - coverage run -m pytest tests - coverage report + poetry run coverage run -m pytest tests + poetry run coverage report - name: Upload coverage report uses: actions/upload-artifact@v2 From e1f6ad5fdc71345f1b0c061f2d787aa31b6e3fce Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sun, 7 Jul 2024 20:38:12 +0300 Subject: [PATCH 45/97] refactor(tests): updated .coverage --- .coverage | Bin 53248 -> 53248 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/.coverage b/.coverage index fae51ff4d1180b001fe0e39ad9fad224dab0fb05..34ff404edd2a1f11a146c9c03f2f898f52b53008 100644 GIT binary patch delta 823 zcmZvYUr1AN6vyw~?(Vkx=XdVCf=)C;O7qDXnp!Qi2Q}RvA~gbSbDL9VZZn&bMNGpt zi)ue9NI`q?Sr{$?U(Bb{@FD0$(jJ6fB0>=pEjza?_RxJf=llKMa}J;1$c%Ah#yH2- zmS{qNfi(2NNwPsENiz}TMfr|gAk9k=sYv`NJ`wH0vJgnAN~K`v){^d)+`;}%e>~(H z2t~rt&d|WE(*EIq$}yvj^o6IK&bV#V=QPn3C*eC3wCgf$EgX%928R57`cxBOoQrWa zwYh*LwBL1J4#px8f3#E2>t%ErXw2o1{~klPTTHrpOahLcJ=%Q2P-msYQ%9HFjeLU{ zlPpZC%tyCU9#5J@(jWsxWJ+9@UyDg1k`}30ijq(85+d?(DJ_->D?(7di=iTlMsEN=hD>P;7xXk?dxJ@^8f z@CFv)6+DNBFbcyEfy;0aPQxhx;K?qsE|Y|;ajZ38t6b`S`mD-sHmfKVRWoKQ*pw<) z1CzJc%2&4+7T$Sk?#(}7Er7lTy DIk)lP delta 615 zcmZXP&1(};6vb!E%S>k8n|DVO47Np6NaIc$C|b2bHn!vwHFaZKLu1A?sY#l|4_vkV z3o3ci5-L~`T#67(K@co)Ar%p~Eu=0=MUXCZW2hK!`~bnXx#w^W@BZ%GifwMi_LKy? zCgl>;;W~Kv7GLIZ&WwhUWt`~~qoblz36a+!R}DkWyD zQl^kPHghKl;%z9YggAB%iHEU4@h%L)7a0;=5hgRS6+UC+ta5oWZI!V;Ohq9wV%M=9 z@BAoe)Fo@e6TK|fqCw@HiUDA7%69WcrWT9UWdsEY`uKfz)ObZV`6w%}RsI21A!kgn zHoZ-O8wmPQCuxG1#l&fm54+v}qncOAeDH7X%uP;PiEJU6$aKztFhTrcVnD2iouY62 zEaFqy5Y4e6_76T2`SB4uogzs8XAxfDlyz}rVoa<}gvc;KevE$eU#a(cP4G+5g3quG zZ=eCs;R!r~Wmtd`q+k?+5CA{&YcOk=MAmxD6OHuZ(#hkh3TRC@VAD7X7vHE>Wp1E6 zY%DhSmOj0h^~;Q*?_$lEBukQ`o1z<}yL;c!{d(V?ZMKW8t@iYnZe7RlpzeEo?_x;N zP;*Vz95`K$_4p;Ht`2l^H9J=M+M_C{O=VG4?46>0pPS(9C%ZdKo|i3WASMxyESayX Xn%G|O*i5Jh^=>a-k%e02LB9VdbJ?++ From d8097aa35de95c50891de622a4d7d6935c1fd061 Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sun, 7 Jul 2024 20:43:27 +0300 Subject: [PATCH 46/97] feat(linter): added ruff(linter) --- bot/work_time.py | 1 - poetry.lock | 29 ++++++++++++++++++++++++++++- pyproject.toml | 1 + 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index d1a0d74..c95260e 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -6,7 +6,6 @@ from aiogram.types import Message, CallbackQuery from aiogram.fsm.context import FSMContext from aiogram.utils import markdown as fmt -from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler from .commands import bot_command_names diff --git a/poetry.lock b/poetry.lock index 1270696..65f7757 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1174,6 +1174,33 @@ files = [ {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] +[[package]] +name = "ruff" +version = "0.5.1" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.5.1-py3-none-linux_armv6l.whl", hash = "sha256:6ecf968fcf94d942d42b700af18ede94b07521bd188aaf2cd7bc898dd8cb63b6"}, + {file = "ruff-0.5.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:204fb0a472f00f2e6280a7c8c7c066e11e20e23a37557d63045bf27a616ba61c"}, + {file = "ruff-0.5.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d235968460e8758d1e1297e1de59a38d94102f60cafb4d5382033c324404ee9d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38beace10b8d5f9b6bdc91619310af6d63dd2019f3fb2d17a2da26360d7962fa"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5e478d2f09cf06add143cf8c4540ef77b6599191e0c50ed976582f06e588c994"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0368d765eec8247b8550251c49ebb20554cc4e812f383ff9f5bf0d5d94190b0"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3a9a9a1b582e37669b0138b7c1d9d60b9edac880b80eb2baba6d0e566bdeca4d"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bdd9f723e16003623423affabcc0a807a66552ee6a29f90eddad87a40c750b78"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9fd62c1e99539da05fcdc1e90d20f74aec1b7a1613463ed77870057cd6bd96"}, + {file = "ruff-0.5.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e216fc75a80ea1fbd96af94a6233d90190d5b65cc3d5dfacf2bd48c3e067d3e1"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c4c2112e9883a40967827d5c24803525145e7dab315497fae149764979ac7929"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dfaf11c8a116394da3b65cd4b36de30d8552fa45b8119b9ef5ca6638ab964fa3"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d7ceb9b2fe700ee09a0c6b192c5ef03c56eb82a0514218d8ff700f6ade004108"}, + {file = "ruff-0.5.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bac6288e82f6296f82ed5285f597713acb2a6ae26618ffc6b429c597b392535c"}, + {file = "ruff-0.5.1-py3-none-win32.whl", hash = "sha256:5c441d9c24ec09e1cb190a04535c5379b36b73c4bc20aa180c54812c27d1cca4"}, + {file = "ruff-0.5.1-py3-none-win_amd64.whl", hash = "sha256:b1789bf2cd3d1b5a7d38397cac1398ddf3ad7f73f4de01b1e913e2abc7dfc51d"}, + {file = "ruff-0.5.1-py3-none-win_arm64.whl", hash = "sha256:2875b7596a740cbbd492f32d24be73e545a4ce0a3daf51e4f4e609962bfd3cd2"}, + {file = "ruff-0.5.1.tar.gz", hash = "sha256:3164488aebd89b1745b47fd00604fb4358d774465f20d1fcd907f9c0fc1b0655"}, +] + [[package]] name = "sentinels" version = "1.0.0" @@ -1362,4 +1389,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "b36b1cd4ef5faa882b2a8a0fd5f22fb4626e46be1bb0d5a154f88a85ebd55350" +content-hash = "7701f5340e25b3c9369ba6745b40ab431635d5cbb3074c8cb050458bebf24f21" diff --git a/pyproject.toml b/pyproject.toml index f901def..98a6a8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ pytest = "^8.2.2" pytest-asyncio = "^0.23.7" mongomock-motor = "^0.0.30" coverage = "^7.5.4" +ruff = "^0.5.1" [tool.poetry.scripts] bot = "bot.main:main" From 737b04768addb16dc636a17eb67302380a29fa50 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Thu, 27 Jun 2024 18:22:07 +0300 Subject: [PATCH 47/97] feat(keyboards): add file for keyboards --- bot/keyboards.py | 131 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 bot/keyboards.py diff --git a/bot/keyboards.py b/bot/keyboards.py new file mode 100644 index 0000000..e8e7bf8 --- /dev/null +++ b/bot/keyboards.py @@ -0,0 +1,131 @@ +from typing import List, Dict, Optional +from datetime import datetime, time + +from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton +from pytz import timezone, utc + +INCLUDED_1 = "โœ…" +INCLUDED_2 = "๐Ÿ—น" +INCLUDED_3 = "๐ŸŸฉ" +NOT_INCLUDED_1 = "โŽ" +NOT_INCLUDED_2 = "โ˜" +NOT_INCLUDED_3 = "๐ŸŸฅ" +ADD = "โž•" +REMOVE = "โœ–๏ธ" + + +# TODO: merge overlapping intervals +# TODO: sort intervals +class Interval: + def __init__(self, start_time: time, end_time: time, tz: timezone = utc): + self.start_time = start_time + self.end_time = end_time + self.tz = tz + + @classmethod + def from_string(cls, interval_str: str, tz: timezone = utc): + start_str, end_str = interval_str.replace(" ", "").split('-') + start_time = cls.parse_time(start_str) + end_time = cls.parse_time(end_str) + return cls(start_time, end_time, tz) + + @staticmethod + def parse_time(time_str: str) -> time: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + elif '.' in time_str: + return datetime.strptime(time_str, "%H.%M").time() + else: + raise ValueError("Time format must be either HH:MM or HH.MM") + + def to_string(self): + return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" + + def convert_to_timezone(self, new_tz: timezone): + start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) + end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) + new_start_dt = start_dt.astimezone(new_tz) + new_end_dt = end_dt.astimezone(new_tz) + self.start_time = new_start_dt.time() + self.end_time = new_end_dt.time() + self.tz = new_tz + + def overlaps_with(self, other): + if self.tz != other.tz: + raise ValueError("Time zones must match to compare intervals") + + start_a = datetime.combine(datetime.today(), self.start_time) + end_a = datetime.combine(datetime.today(), self.end_time) + start_b = datetime.combine(datetime.today(), other.start_time) + end_b = datetime.combine(datetime.today(), other.end_time) + + return max(start_a, start_b) < min(end_a, end_b) + + def to_keyboard(self, weekday: str) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + interval_str = self.to_string() + + builder.button(text=interval_str, callback_data=f"{weekday}_interval_{interval_str}") + builder.button(text=REMOVE, callback_data=f"{weekday}_remove_{interval_str}") + builder.button(text=ADD, callback_data=f"{weekday}_add") + builder.adjust(3) + + return builder + + def __eq__(self, other): + if not isinstance(other, Interval): + return False + return (self.start_time == other.start_time and + self.end_time == other.end_time and + self.tz == other.tz) + + def __str__(self): + return self.to_string() + + def __repr__(self): + return f"Interval({self.to_string()}, tz={self.tz})" + + +class DaySchedule: + def __init__(self, name: str, included: bool = False, intervals: Optional[List[Interval]] = None): + self.name = name + self.included = included + self.intervals: List[Interval] = intervals if intervals else [] + + def toggle_inclusion(self): + self.included = not self.included + + def add_interval(self, interval: Interval): + self.intervals.append(interval) + + def remove_interval(self, interval: Interval): + self.intervals = [i for i in self.intervals if i != interval] + + def to_keyboard(self) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + included_text = INCLUDED_3 if self.included else NOT_INCLUDED_3 + + day = InlineKeyboardButton(text=f"{self.name}", callback_data=f"{self.name}") + status = InlineKeyboardButton(text=included_text, callback_data=f"{self.name}_toggle") + builder.row(day, status) + + if self.included: + for interval in self.intervals: + interval_builder = interval.to_keyboard(weekday=self.name) + builder.attach(interval_builder) + + return builder + + +class WeekSchedule: + def __init__(self, schedule: Dict[str, DaySchedule]): + self.schedule = schedule + + def to_keyboard(self) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + for weekday in self.schedule.values(): + weekday_builder = weekday.to_keyboard() + builder.attach(weekday_builder) + + return builder.as_markup() From 4bb9a988b9e685ef2c4136570073976edbf531ff Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Thu, 27 Jun 2024 18:23:37 +0300 Subject: [PATCH 48/97] feat(schedule): add file for handling user working time setting --- bot/work_time.py | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 bot/work_time.py diff --git a/bot/work_time.py b/bot/work_time.py new file mode 100644 index 0000000..ae9b49a --- /dev/null +++ b/bot/work_time.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from aiogram import Bot, Router +from aiogram.filters.command import Command +from aiogram.types import Message +from aiogram.utils.i18n import gettext as _ +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +from .commands import bot_command_names +from .custom_types import SendMessage +from .filters import HasChatState, HasMessageText, HasMessageUserUsername +from .keyboards import Interval, DaySchedule, WeekSchedule +from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm + + +def handle_working_time( + scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot +): + @router.message( + Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState() + ) + async def show_user_schedule( + message: Message, username: str, chat_state: ChatState + ): + my_schedule = { + "Monday": DaySchedule("Monday", True, [Interval.from_string("09:00 - 18:00")]), + "Tuesday": DaySchedule("Tuesday", False), + "Wednesday": DaySchedule("Wednesday", True, [Interval.from_string("10:00 - 17:00")]), + "Thursday": DaySchedule("Thursday", False), + "Friday": DaySchedule("Friday", True, [Interval.from_string("09:00 - 18:00")]), + "Saturday": DaySchedule("Saturday", False), + "Sunday": DaySchedule("Sunday", False) + } + + week_schedule = WeekSchedule(schedule=my_schedule) + + await message.answer( + text=f"@{username}, here is your schedule:", + reply_markup=week_schedule.to_keyboard() + ) From 3f6e1feff06279cad6025136b7f65cab5f83b740 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Thu, 27 Jun 2024 18:28:21 +0300 Subject: [PATCH 49/97] feat(commands): add /set_personal_working_time to commands and modify handlers file for work time settings --- bot/commands.py | 3 +++ bot/handlers.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/bot/commands.py b/bot/commands.py index a7bf67f..f7e39df 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -19,6 +19,7 @@ class BotCommands(BaseModel): join: str skip: str set_personal_meetings_days: str + set_personal_working_time: str set_reminder_period: str join_today: str skip_today: str @@ -47,6 +48,7 @@ class BotCommandNames(BotCommands): join="join", skip="skip", set_personal_meetings_days="set_personal_meetings_days", + set_personal_working_time="set_personal_working_time", set_reminder_period="set_reminder_period", join_today="join_today", skip_today="skip_today", @@ -78,6 +80,7 @@ def bot_command_descriptions() -> BotCommandDescriptions: join=_("Join meetings."), skip=_("Skip meetings."), set_personal_meetings_days=_("Set the days when you can join meetings."), + set_personal_working_time=_("Set your working schedule."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), skip_today=_("Skip only today's meeting."), diff --git a/bot/handlers.py b/bot/handlers.py index 567b5b0..43e58ee 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -15,6 +15,7 @@ from .filters import HasChatState, HasMessageText, HasMessageUserUsername, IsReplyToMeetingMessage from .meeting import schedule_meeting from .reminder import update_reminders +from .work_time import handle_working_time from .messages import make_help_message from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -34,6 +35,10 @@ def make_router(scheduler: AsyncIOScheduler, send_message: SendMessage, bot: Bot scheduler=scheduler, send_message=send_message, router=router, bot=bot ) + handle_working_time( + scheduler=scheduler, send_message=send_message, router=router, bot=bot + ) + handle_info_commands( scheduler=scheduler, send_message=send_message, router=router ) From eef5add98da3c6bb61192dc4b4a08ed6edf7d3a7 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:16:37 +0300 Subject: [PATCH 50/97] feat(intervals): add files to store callback dataclasses and pydantic models for time intervals --- bot/callbacks.py | 0 bot/intervals.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 bot/callbacks.py create mode 100644 bot/intervals.py diff --git a/bot/callbacks.py b/bot/callbacks.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/intervals.py b/bot/intervals.py new file mode 100644 index 0000000..e69de29 From 631c95c6fc5644dc9903d870068e6db7ec898368 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:17:42 +0300 Subject: [PATCH 51/97] feat(callbacks): add callbacks for intervals editing and weekday toggling --- bot/callbacks.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/callbacks.py b/bot/callbacks.py index e69de29..f3b2364 100644 --- a/bot/callbacks.py +++ b/bot/callbacks.py @@ -0,0 +1,12 @@ +from aiogram.filters.callback_data import CallbackData + + +class IntervalCallback(CallbackData, prefix="interval"): + weekday: str + interval: str + action: str # add, remove, edit + + +class WeekdayCallback(CallbackData, prefix="weekday"): + weekday: str + action: str # toggle From 1fd29d35e3e81ca4b1089b9303ad3be5c45574ab Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:19:25 +0300 Subject: [PATCH 52/97] feat(intervals): add base models for time intervals and weekdays Time intervals now have methods to merge and sort multiple intervals --- bot/intervals.py | 134 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/bot/intervals.py b/bot/intervals.py index e69de29..69a8db1 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -0,0 +1,134 @@ +from typing import List, Dict +from datetime import datetime, time + +from pydantic import BaseModel, computed_field +from pytz import timezone, utc + + +class Interval(BaseModel): + start_time: time + end_time: time + tz: timezone = utc + + @computed_field + @property + def start_time_utc(self) -> time: + return self.convert_to_utc(self.start_time) + + @computed_field + @property + def end_time_utc(self) -> time: + return self.convert_to_utc(self.end_time) + + def convert_to_utc(self, local_time: time) -> time: + local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=self.tz) + utc_dt = local_dt.astimezone(utc) + return utc_dt.time() + + @classmethod + def from_string(cls, interval_str: str, tz: timezone): + start_str, end_str = interval_str.replace(" ", "").split('-') + start_time = cls.parse_time(start_str) + end_time = cls.parse_time(end_str) + return cls(start_time=start_time, end_time=end_time, tz=tz) + + @staticmethod + def parse_time(time_str: str) -> time: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + elif '.' in time_str: + return datetime.strptime(time_str, "%H.%M").time() + else: + raise ValueError("Time format must be either HH:MM or HH.MM") + + def convert_to_timezone(self, new_tz: timezone): + start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) + end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) + new_start_dt = start_dt.astimezone(new_tz) + new_end_dt = end_dt.astimezone(new_tz) + return Interval(start_time=new_start_dt.time(), end_time=new_end_dt.time(), tz=new_tz) + + def to_string(self): + return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" + + def __hash__(self): + return hash((self.start_time_utc, self.end_time_utc)) + + def __eq__(self, other): + if not isinstance(other, Interval): + return False + return (self.start_time_utc == other.start_time_utc and + self.end_time_utc == other.end_time_utc) + + def __str__(self): + return self.to_string() + + def __repr__(self): + return f"Interval({self.to_string()}, tz={self.tz})" + + def overlaps_with(self, other): + start_a = self.start_time_utc + end_a = self.end_time_utc + start_b = other.start_time_utc + end_b = other.end_time_utc + + return max(start_a, start_b) < min(end_a, end_b) + + @staticmethod + def merge_intervals(intervals): + distinct_tzs = set([interval.tz for interval in intervals]) + + if len(distinct_tzs) != 1: + raise ValueError("Intervals have to have same tz") + + if not intervals: + return [] + + # Sort intervals by start time + sorted_intervals = sorted(intervals, key=lambda x: x.start_time) + + merged_intervals = [sorted_intervals[0]] + for current in sorted_intervals[1:]: + last = merged_intervals[-1] + + if current.overlaps_with(last) or current.start_time <= last.end_time: + # Merge intervals + merged_intervals[-1] = Interval( + start_time=min(last.start_time, current.start_time), + end_time=max(last.end_time, current.end_time), + tz=last.tz + ) + else: + merged_intervals.append(current) + + return merged_intervals + + +class DaySchedule(BaseModel): + name: str + included: bool = True + intervals: List[Interval] = [] + + def toggle_inclusion(self): + self.included = not self.included + + def add_interval(self, interval: Interval): + self.intervals.append(interval) + + def remove_interval(self, interval: Interval): + self.intervals = [i for i in self.intervals if i != interval] + + +class WeekSchedule(BaseModel): + self.schedule: Dict[str, DaySchedule] = dict() + self.tz = tz + self.shift = shift + + for weekday in schedule: + + intervals = [] + for interval in schedule[weekday]["intervals"]: + intervals.append(Interval.from_string(interval, self.tz)) + + day_schedule = DaySchedule(weekday, schedule[weekday]["include"], intervals) + self.schedule[weekday] = day_schedule From a0ec6e01ce05af148c1fe949ba182b567cec1429 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:20:53 +0300 Subject: [PATCH 53/97] fix(intervals): made some minor changes to make classes simplier --- bot/intervals.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 69a8db1..e2ea97a 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -117,18 +117,3 @@ def add_interval(self, interval: Interval): def remove_interval(self, interval: Interval): self.intervals = [i for i in self.intervals if i != interval] - - -class WeekSchedule(BaseModel): - self.schedule: Dict[str, DaySchedule] = dict() - self.tz = tz - self.shift = shift - - for weekday in schedule: - - intervals = [] - for interval in schedule[weekday]["intervals"]: - intervals.append(Interval.from_string(interval, self.tz)) - - day_schedule = DaySchedule(weekday, schedule[weekday]["include"], intervals) - self.schedule[weekday] = day_schedule From 22a53625130b23d64df3ef87c23937eda2ed5161 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:22:02 +0300 Subject: [PATCH 54/97] feat(keyboards): add callbacks and inline queries to buttons --- bot/keyboards.py | 137 +++++++++++------------------------------------ 1 file changed, 30 insertions(+), 107 deletions(-) diff --git a/bot/keyboards.py b/bot/keyboards.py index e8e7bf8..eafdd93 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -1,8 +1,6 @@ -from typing import List, Dict, Optional -from datetime import datetime, time - from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton -from pytz import timezone, utc + +from .callbacks import IntervalCallback, WeekdayCallback INCLUDED_1 = "โœ…" INCLUDED_2 = "๐Ÿ—น" @@ -14,118 +12,43 @@ REMOVE = "โœ–๏ธ" -# TODO: merge overlapping intervals -# TODO: sort intervals -class Interval: - def __init__(self, start_time: time, end_time: time, tz: timezone = utc): - self.start_time = start_time - self.end_time = end_time - self.tz = tz - - @classmethod - def from_string(cls, interval_str: str, tz: timezone = utc): - start_str, end_str = interval_str.replace(" ", "").split('-') - start_time = cls.parse_time(start_str) - end_time = cls.parse_time(end_str) - return cls(start_time, end_time, tz) - - @staticmethod - def parse_time(time_str: str) -> time: - if ':' in time_str: - return datetime.strptime(time_str, "%H:%M").time() - elif '.' in time_str: - return datetime.strptime(time_str, "%H.%M").time() - else: - raise ValueError("Time format must be either HH:MM or HH.MM") - - def to_string(self): - return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" - - def convert_to_timezone(self, new_tz: timezone): - start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) - end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) - new_start_dt = start_dt.astimezone(new_tz) - new_end_dt = end_dt.astimezone(new_tz) - self.start_time = new_start_dt.time() - self.end_time = new_end_dt.time() - self.tz = new_tz - - def overlaps_with(self, other): - if self.tz != other.tz: - raise ValueError("Time zones must match to compare intervals") - - start_a = datetime.combine(datetime.today(), self.start_time) - end_a = datetime.combine(datetime.today(), self.end_time) - start_b = datetime.combine(datetime.today(), other.start_time) - end_b = datetime.combine(datetime.today(), other.end_time) - - return max(start_a, start_b) < min(end_a, end_b) - - def to_keyboard(self, weekday: str) -> InlineKeyboardBuilder: - builder = InlineKeyboardBuilder() - interval_str = self.to_string() - - builder.button(text=interval_str, callback_data=f"{weekday}_interval_{interval_str}") - builder.button(text=REMOVE, callback_data=f"{weekday}_remove_{interval_str}") - builder.button(text=ADD, callback_data=f"{weekday}_add") - builder.adjust(3) - - return builder - - def __eq__(self, other): - if not isinstance(other, Interval): - return False - return (self.start_time == other.start_time and - self.end_time == other.end_time and - self.tz == other.tz) - - def __str__(self): - return self.to_string() +def get_interval_keyboard(interval: str, weekday: str) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + interval_std = interval.replace(":", "|") - def __repr__(self): - return f"Interval({self.to_string()}, tz={self.tz})" + builder.button(text=interval, switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}") + builder.button(text=REMOVE, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='remove')) + builder.button(text=ADD, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='add')) + builder.adjust(3) + return builder -class DaySchedule: - def __init__(self, name: str, included: bool = False, intervals: Optional[List[Interval]] = None): - self.name = name - self.included = included - self.intervals: List[Interval] = intervals if intervals else [] - def toggle_inclusion(self): - self.included = not self.included +def get_weekday_keyboard(weekday: str, content: dict) -> InlineKeyboardBuilder: + builder = InlineKeyboardBuilder() + included_text = INCLUDED_3 if content["include"] else NOT_INCLUDED_3 - def add_interval(self, interval: Interval): - self.intervals.append(interval) + day = InlineKeyboardButton(text=f"{weekday}", callback_data="#") + status = InlineKeyboardButton( + text=included_text, + callback_data=WeekdayCallback(weekday=weekday, action="toggle").pack() + ) - def remove_interval(self, interval: Interval): - self.intervals = [i for i in self.intervals if i != interval] + builder.row(day, status) - def to_keyboard(self) -> InlineKeyboardBuilder: - builder = InlineKeyboardBuilder() - included_text = INCLUDED_3 if self.included else NOT_INCLUDED_3 + if content["include"]: + for interval in content["intervals"]: + interval_builder = get_interval_keyboard(interval, weekday) + builder.attach(interval_builder) - day = InlineKeyboardButton(text=f"{self.name}", callback_data=f"{self.name}") - status = InlineKeyboardButton(text=included_text, callback_data=f"{self.name}_toggle") - builder.row(day, status) + return builder - if self.included: - for interval in self.intervals: - interval_builder = interval.to_keyboard(weekday=self.name) - builder.attach(interval_builder) - - return builder - - -class WeekSchedule: - def __init__(self, schedule: Dict[str, DaySchedule]): - self.schedule = schedule - def to_keyboard(self) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() +def get_schedule_keyboard(week_schedule: dict) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() - for weekday in self.schedule.values(): - weekday_builder = weekday.to_keyboard() - builder.attach(weekday_builder) + for weekday, content in week_schedule.items(): + weekday_builder = get_weekday_keyboard(weekday, content) + builder.attach(weekday_builder) - return builder.as_markup() + return builder.as_markup() From 577fcd85694b2f8f7f2dc49f9e4a9eb252e39fbb Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:22:53 +0300 Subject: [PATCH 55/97] feat(schedule): add all necessary handlers for menu with schedule --- bot/work_time.py | 160 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 137 insertions(+), 23 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index ae9b49a..fb9fd18 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -1,40 +1,154 @@ +import re from datetime import datetime -from aiogram import Bot, Router +from aiogram import Bot, Router, F from aiogram.filters.command import Command -from aiogram.types import Message +from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler +from pytz import timezone, utc from .commands import bot_command_names from .custom_types import SendMessage -from .filters import HasChatState, HasMessageText, HasMessageUserUsername -from .keyboards import Interval, DaySchedule, WeekSchedule +from .callbacks import IntervalCallback, WeekdayCallback +# from .intervals import WeekSchedule, DaySchedule, Interval +from .filters import HasChatState, HasMessageUserUsername +from .keyboards import get_schedule_keyboard from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm +EDIT_HANDLE_IVL = \ + r'^edit\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\s*-->\s*\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' + +EDIT_PARSE_IVL = \ + r'^edit\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})\s*-->' \ + r'\s*(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' + +ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' +ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' + +schedule_db = { + "Monday": { + "include": True, + "intervals": ["9:00 - 18:00"] + }, + "Tuesday": { + "include": False, + "intervals": [] + }, + "Wednesday": { + "include": True, + "intervals": ["10:00 - 17:00"] + }, + "Thursday": { + "include": False, + "intervals": [] + }, + "Friday": { + "include": True, + "intervals": ["9:00 - 18:00"] + }, + "Saturday": { + "include": False, + "intervals": [] + }, + "Sunday": { + "include": False, + "intervals": [] + }, + } + +chat_db = { + "schedule_msg": None, + "tz": timezone("Europe/Moscow"), + "shift": 0 +} + + def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message( - Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState() - ) - async def show_user_schedule( - message: Message, username: str, chat_state: ChatState - ): - my_schedule = { - "Monday": DaySchedule("Monday", True, [Interval.from_string("09:00 - 18:00")]), - "Tuesday": DaySchedule("Tuesday", False), - "Wednesday": DaySchedule("Wednesday", True, [Interval.from_string("10:00 - 17:00")]), - "Thursday": DaySchedule("Thursday", False), - "Friday": DaySchedule("Friday", True, [Interval.from_string("09:00 - 18:00")]), - "Saturday": DaySchedule("Saturday", False), - "Sunday": DaySchedule("Sunday", False) - } + @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) + async def show_user_schedule(message: Message, username: str, chat_state: ChatState): + # sch = WeekSchedule(schedule_db, tz=utc, shift=0) + for weekday in schedule_db: + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + chat_db["schedule_msg"] = schedule_msg - week_schedule = WeekSchedule(schedule=my_schedule) + @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) + async def show_inline_interval_editing(inline_query: InlineQuery): - await message.answer( - text=f"@{username}, here is your schedule:", - reply_markup=week_schedule.to_keyboard() + suggestion = InlineQueryResultArticle( + id=inline_query.query, + title=inline_query.query, + input_message_content=InputTextMessageContent( + message_text=inline_query.query + ) ) + await inline_query.answer([suggestion], is_personal=True) + + @router.message(F.text.regexp(EDIT_HANDLE_IVL), HasMessageUserUsername(), HasChatState()) + async def handle_interval_editing(message: Message, username: str, chat_state: ChatState): + + parse_pattern = re.compile(EDIT_PARSE_IVL) + parse_match = parse_pattern.match(message.text) + if parse_match: + weekday = parse_match.group("weekday") + st_time_prev = parse_match.group("start_time1") + end_time_prev = parse_match.group("end_time1") + st_time_edit = parse_match.group("start_time2") + end_time_edit = parse_match.group("end_time2") + + interval_prev = f"{st_time_prev} - {end_time_prev}" + interval_edit = f"{st_time_edit} - {end_time_edit}" + + intervals = schedule_db[weekday]["intervals"] + if interval_prev in intervals: + intervals.remove(interval_prev) + intervals.append(interval_edit) + schedule_db[weekday]["intervals"] = intervals + + layout = get_schedule_keyboard(schedule_db) + if chat_db["schedule_msg"]: + old_msg = chat_db["schedule_msg"] + await old_msg.edit_reply_markup(reply_markup=layout) + + await message.delete() + + @router.callback_query(IntervalCallback.filter(F.action == 'add')) + async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): + + weekday = callback_data.weekday + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + await cb.message.edit_reply_markup(reply_markup=layout) + + @router.callback_query(IntervalCallback.filter(F.action == 'remove')) + async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): + + weekday = callback_data.weekday + interval = callback_data.interval.replace("|", ":") + schedule_db[weekday]["intervals"].remove(interval) + + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + await cb.message.edit_reply_markup(reply_markup=layout) + + @router.callback_query(WeekdayCallback.filter(F.action == 'toggle')) + async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): + + weekday = callback_data.weekday + schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] + + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append("23:59 - 23:59") + + layout = get_schedule_keyboard(schedule_db) + await cb.message.edit_reply_markup(reply_markup=layout) From bbb2e2de84c256b299166b3f33180a81566e56ca Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 00:24:06 +0300 Subject: [PATCH 56/97] fix(reminders): fix bug with phantom notifications (again) --- bot/reminder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/reminder.py b/bot/reminder.py index 8a06a0f..08bd59b 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -57,7 +57,7 @@ async def send_reminder_messages( reply_to_message_id=message_id ) - if chat_type == "supergroup" and len(chat_state.meeting_msg_ids) == 3: + if chat_type == "supergroup" and len(chat_state.meeting_msg_ids) == 3 and len(have_to_reply) > 0: await send_message( chat_id=user_chat_id, message=reminder_message From e55b83f7371d934c82d4a327efba372e85ac871f Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:24:45 +0300 Subject: [PATCH 57/97] feat(intervals): add interval validation and exceptions --- bot/intervals.py | 76 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 56 insertions(+), 20 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index e2ea97a..94d54fa 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,14 +1,36 @@ -from typing import List, Dict +from typing import List from datetime import datetime, time -from pydantic import BaseModel, computed_field -from pytz import timezone, utc +from pydantic import BaseModel, computed_field, field_validator +from pytz import timezone, utc, UnknownTimeZoneError + + +class IntervalException(Exception): + """Base class for other Interval exceptions""" + pass + + +class InvalidTimeFormatException(IntervalException): + """Raised when the time format is invalid""" + def __init__(self, time_str, message="Time format must be either HH:MM or HH.MM"): + self.time_str = time_str + self.message = message + super().__init__(self.message) + + +class InvalidIntervalException(IntervalException): + """Raised when the interval is invalid""" + def __init__(self, start_time, end_time, message="Start time must be earlier than end time"): + self.start_time = start_time + self.end_time = end_time + self.message = message + super().__init__(self.message) class Interval(BaseModel): start_time: time end_time: time - tz: timezone = utc + tz: str = "UTC" @computed_field @property @@ -20,32 +42,46 @@ def start_time_utc(self) -> time: def end_time_utc(self) -> time: return self.convert_to_utc(self.end_time) + @field_validator("tz") + def zone_must_be_valid(cls, tz: str): + try: + timezone(tz) + except UnknownTimeZoneError: + raise ValueError("You should pass valid zone name") + def convert_to_utc(self, local_time: time) -> time: - local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=self.tz) + local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=timezone(self.tz)) utc_dt = local_dt.astimezone(utc) return utc_dt.time() @classmethod - def from_string(cls, interval_str: str, tz: timezone): + def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') start_time = cls.parse_time(start_str) end_time = cls.parse_time(end_str) + + if start_time >= end_time: + raise InvalidIntervalException(start_time, end_time) + return cls(start_time=start_time, end_time=end_time, tz=tz) @staticmethod def parse_time(time_str: str) -> time: - if ':' in time_str: - return datetime.strptime(time_str, "%H:%M").time() - elif '.' in time_str: - return datetime.strptime(time_str, "%H.%M").time() - else: - raise ValueError("Time format must be either HH:MM or HH.MM") - - def convert_to_timezone(self, new_tz: timezone): - start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=self.tz) - end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=self.tz) - new_start_dt = start_dt.astimezone(new_tz) - new_end_dt = end_dt.astimezone(new_tz) + try: + if ':' in time_str: + return datetime.strptime(time_str, "%H:%M").time() + elif '.' in time_str: + return datetime.strptime(time_str, "%H.%M").time() + else: + raise InvalidTimeFormatException(time_str) + except ValueError: + raise InvalidTimeFormatException(time_str) + + def convert_to_timezone(self, new_tz: str): + start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=timezone(self.tz)) + end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=timezone(self.tz)) + new_start_dt = start_dt.astimezone(timezone(new_tz)) + new_end_dt = end_dt.astimezone(timezone(new_tz)) return Interval(start_time=new_start_dt.time(), end_time=new_end_dt.time(), tz=new_tz) def to_string(self): @@ -76,10 +112,10 @@ def overlaps_with(self, other): @staticmethod def merge_intervals(intervals): - distinct_tzs = set([interval.tz for interval in intervals]) + distinct_tzs = set([timezone(interval.tz).utcoffset(datetime.now()) for interval in intervals]) if len(distinct_tzs) != 1: - raise ValueError("Intervals have to have same tz") + raise ValueError("Intervals have to have same timezone offset") if not intervals: return [] From 8272ce863a840bf632509b4467ef9889a74dd45a Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:25:48 +0300 Subject: [PATCH 58/97] feat(messages): add messages if user entered invalid interval --- bot/messages.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bot/messages.py b/bot/messages.py index 8c10d42..de8e84c 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from datetime import datetime from textwrap import dedent -from typing import List +from typing import List, Tuple from aiogram import html from aiogram.utils.i18n import gettext as _ @@ -9,6 +9,7 @@ from .commands import bot_command_descriptions, bot_command_names from .constants import day_of_week_pretty from .language import Language +from .intervals import Interval, InvalidTimeFormatException, InvalidIntervalException def bot_intro(): @@ -56,3 +57,23 @@ def make_daily_messages(usernames: str) -> List[str]: usernames=usernames ), ] + + +def make_interval_validation_message(interval_str: str, tz: str) -> Tuple[bool, str]: + try: + interval = Interval.from_string(interval_str=interval_str, tz=tz) + msg = _("Successfully parsed interval: {interval}").format(interval=interval) + return True, msg + except InvalidTimeFormatException as e: + msg = _("Error: Invalid time format for '{time}'. {msg}").format(time=e.time_str, msg=e.message) + return False, msg + except InvalidIntervalException as e: + msg = _("Error: {msg} (start: {start}, end: {end})").format( + msg=e.message, + start=e.start_time, + end=e.end_time + ) + return False, msg + except Exception as e: + msg = _("Error: An unexpected error occurred. {error}").format(error=str(e)) + return False, msg From c1768198f4304f16ca3e8474377bc4c77c57ccfa Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:27:37 +0300 Subject: [PATCH 59/97] feat(keyboards): add keyboard markup for wrong interval input --- bot/keyboards.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bot/keyboards.py b/bot/keyboards.py index eafdd93..a8fcaca 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -52,3 +52,15 @@ def get_schedule_keyboard(week_schedule: dict) -> InlineKeyboardMarkup: builder.attach(weekday_builder) return builder.as_markup() + + +def get_interval_error_keyboard(interval: str, weekday: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button(text="Cancel", callback_data="cancel_editing_interval") + builder.button( + text="Enter the interval again", + switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}" + ) + + return builder.adjust(2).as_markup() From 5e52323cef32b90a0e3254a0d01717b9136a2e90 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 15:29:03 +0300 Subject: [PATCH 60/97] feat(schedule): add handlers for wrong input and reentering intervals --- bot/work_time.py | 72 ++++++++++++++++++++++++++++++++++-------------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index fb9fd18..9159fe9 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -3,17 +3,18 @@ from aiogram import Bot, Router, F from aiogram.filters.command import Command +from aiogram.fsm.context import FSMContext from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler -from pytz import timezone, utc from .commands import bot_command_names from .custom_types import SendMessage +from .messages import make_interval_validation_message from .callbacks import IntervalCallback, WeekdayCallback -# from .intervals import WeekSchedule, DaySchedule, Interval +from .intervals import Interval from .filters import HasChatState, HasMessageUserUsername -from .keyboards import get_schedule_keyboard +from .keyboards import get_schedule_keyboard, get_interval_error_keyboard from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -26,6 +27,7 @@ ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' +DEFAULT_INTERVAL = "23:59 - 23:59" schedule_db = { "Monday": { @@ -55,13 +57,16 @@ "Sunday": { "include": False, "intervals": [] - }, + } } chat_db = { + "default_working_time": "9:00 - 17:00", "schedule_msg": None, - "tz": timezone("Europe/Moscow"), - "shift": 0 + "tz": "Europe/Moscow", + "shift": 0, + "edit_interval_message": None, + "message_with_keyboard": None } @@ -70,10 +75,10 @@ def handle_working_time( ): @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) async def show_user_schedule(message: Message, username: str, chat_state: ChatState): - # sch = WeekSchedule(schedule_db, tz=utc, shift=0) + for weekday in schedule_db: if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) @@ -81,6 +86,12 @@ async def show_user_schedule(message: Message, username: str, chat_state: ChatSt @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) async def show_inline_interval_editing(inline_query: InlineQuery): + message_with_keyboard = chat_db["message_with_keyboard"] + edit_interval_message = chat_db["edit_interval_message"] + + if message_with_keyboard is not None and edit_interval_message is not None: + await message_with_keyboard.delete() + await edit_interval_message.delete() suggestion = InlineQueryResultArticle( id=inline_query.query, @@ -106,24 +117,35 @@ async def handle_interval_editing(message: Message, username: str, chat_state: C interval_prev = f"{st_time_prev} - {end_time_prev}" interval_edit = f"{st_time_edit} - {end_time_edit}" - intervals = schedule_db[weekday]["intervals"] - if interval_prev in intervals: - intervals.remove(interval_prev) - intervals.append(interval_edit) - schedule_db[weekday]["intervals"] = intervals + is_valid, status_msg = make_interval_validation_message(interval_str=interval_edit, tz=chat_db["tz"]) + + if is_valid: + + intervals = schedule_db[weekday]["intervals"] + if interval_prev in intervals: + intervals.remove(interval_prev) + intervals.append(interval_edit) + schedule_db[weekday]["intervals"] = intervals - layout = get_schedule_keyboard(schedule_db) - if chat_db["schedule_msg"]: - old_msg = chat_db["schedule_msg"] - await old_msg.edit_reply_markup(reply_markup=layout) + layout = get_schedule_keyboard(schedule_db) + if chat_db["schedule_msg"]: + old_msg = chat_db["schedule_msg"] + await old_msg.edit_reply_markup(reply_markup=layout) - await message.delete() + await message.delete() + + else: + layout = get_interval_error_keyboard(interval_prev, weekday) + keyboard_msg = await message.reply(text=status_msg, reply_markup=layout) + + chat_db["edit_interval_message"] = message + chat_db["message_with_keyboard"] = keyboard_msg @router.callback_query(IntervalCallback.filter(F.action == 'add')) async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): weekday = callback_data.weekday - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @@ -136,7 +158,7 @@ async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): schedule_db[weekday]["intervals"].remove(interval) if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @@ -148,7 +170,15 @@ async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append("23:59 - 23:59") + schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) + + @router.callback_query(F.data == "cancel_editing_interval") + async def cancel_editing_interval(cb: CallbackQuery): + message_with_keyboard = cb.message + edit_interval_message = cb.message.reply_to_message + + await message_with_keyboard.delete() + await edit_interval_message.delete() From 7b50b4f68ea631aeef913a54227e564165759c7b Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Mon, 1 Jul 2024 17:45:52 +0300 Subject: [PATCH 61/97] feat(schedule): add default intervals handling --- bot/commands.py | 6 +++ bot/constants.py | 4 ++ bot/work_time.py | 105 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 104 insertions(+), 11 deletions(-) diff --git a/bot/commands.py b/bot/commands.py index f7e39df..654a925 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -15,11 +15,13 @@ class BotCommands(BaseModel): set_meetings_time_zone: str set_meetings_time: str set_meetings_days: str + set_default_working_time: str # personal settings join: str skip: str set_personal_meetings_days: str set_personal_working_time: str + set_personal_default_working_time: str set_reminder_period: str join_today: str skip_today: str @@ -44,11 +46,13 @@ class BotCommandNames(BotCommands): set_meetings_time_zone="set_meetings_time_zone", set_meetings_time="set_meetings_time", set_meetings_days="set_meetings_days", + set_default_working_time="set_default_working_time", # personal settings join="join", skip="skip", set_personal_meetings_days="set_personal_meetings_days", set_personal_working_time="set_personal_working_time", + set_personal_default_working_time="set_personal_default_working_time", set_reminder_period="set_reminder_period", join_today="join_today", skip_today="skip_today", @@ -76,11 +80,13 @@ def bot_command_descriptions() -> BotCommandDescriptions: set_meetings_time_zone=_("Set meetings time zone."), set_meetings_time=_("Set meetings time."), set_meetings_days=_("Set meetings days."), + set_default_working_time=_("Set default working interval for weekday."), # personal settings join=_("Join meetings."), skip=_("Skip meetings."), set_personal_meetings_days=_("Set the days when you can join meetings."), set_personal_working_time=_("Set your working schedule."), + set_personal_default_working_time=_("Set personal default working interval for weekday."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), skip_today=_("Skip only today's meeting."), diff --git a/bot/constants.py b/bot/constants.py index 5e7703e..648ab2c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -15,6 +15,10 @@ class AppCommands: iso8601 = "ISO 8601" +interval_format = "HH:MM or HH.MM" + +sample_interval = "9:00 - 17:00" + datetime_time_format = "%H:%M %Y-%m-%d" day_of_week = "0-6" diff --git a/bot/work_time.py b/bot/work_time.py index 9159fe9..2230c08 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -1,4 +1,5 @@ import re +from textwrap import dedent from datetime import datetime from aiogram import Bot, Router, F @@ -12,8 +13,9 @@ from .custom_types import SendMessage from .messages import make_interval_validation_message from .callbacks import IntervalCallback, WeekdayCallback +from .constants import interval_format, sample_interval from .intervals import Interval -from .filters import HasChatState, HasMessageUserUsername +from .filters import HasChatState, HasMessageUserUsername, HasMessageText from .keyboards import get_schedule_keyboard, get_interval_error_keyboard from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -27,7 +29,6 @@ ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' -DEFAULT_INTERVAL = "23:59 - 23:59" schedule_db = { "Monday": { @@ -62,6 +63,7 @@ chat_db = { "default_working_time": "9:00 - 17:00", + "personal_default_working_time": "8:00 - 18:00", "schedule_msg": None, "tz": "Europe/Moscow", "shift": 0, @@ -73,16 +75,87 @@ def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): + @router.message(Command(bot_command_names.set_default_working_time), HasMessageText()) + async def set_default_working_time(message: Message, message_text: str): + cmd = "/" + bot_command_names.set_default_working_time + interval = message_text.replace(cmd, "").strip() + + is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") + if is_valid: + chat_db["default_working_time"] = interval + await message.answer( + _( + "Your group default working time is set to {interval}" + ).format(interval=interval) + ) + else: + await message.answer( + dedent( + _( + f""" + Please write group default working time in {interval_format} format. + + Example: + + /{set_default_working_time} {sample_interval} + """ + ).format( + interval_format=interval_format, + set_default_working_time=bot_command_names.set_default_working_time, + sample_interval=sample_interval + ) + ) + ) + + @router.message(Command(bot_command_names.set_personal_default_working_time), HasMessageText()) + async def set_personal_default_working_time(message: Message, message_text: str): + cmd = "/" + bot_command_names.set_personal_default_working_time + interval = message_text.replace(cmd, "").strip() + + is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") + if is_valid: + chat_db["personal_default_working_time"] = interval + await message.answer( + _( + "Your personal default working time is set to {interval}" + ).format(interval=interval) + ) + else: + await message.answer( + dedent( + _( + f""" + Please write your personal default working time in {interval_format} format. + + Example: + + /{set_personal_default_working_time} {sample_interval} + """ + ).format( + interval_format=interval_format, + set_default_working_time=bot_command_names.set_personal_default_working_time, + sample_interval=sample_interval + ) + ) + ) + @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) async def show_user_schedule(message: Message, username: str, chat_state: ChatState): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] - for weekday in schedule_db: - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + if personal_default_interval is not None or group_default_interval is not None: + default = personal_default_interval if personal_default_interval is not None else group_default_interval - layout = get_schedule_keyboard(schedule_db) - schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) - chat_db["schedule_msg"] = schedule_msg + for weekday in schedule_db: + if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: + schedule_db[weekday]["intervals"].append(default) + + layout = get_schedule_keyboard(schedule_db) + schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + chat_db["schedule_msg"] = schedule_msg + else: + await message.answer("You should set up default group or personal working time first!") @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) async def show_inline_interval_editing(inline_query: InlineQuery): @@ -144,33 +217,43 @@ async def handle_interval_editing(message: Message, username: str, chat_state: C @router.callback_query(IntervalCallback.filter(F.action == 'add')) async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] + default = personal_default_interval if personal_default_interval is not None else group_default_interval + weekday = callback_data.weekday - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + schedule_db[weekday]["intervals"].append(default) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @router.callback_query(IntervalCallback.filter(F.action == 'remove')) async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] + default = personal_default_interval if personal_default_interval is not None else group_default_interval weekday = callback_data.weekday interval = callback_data.interval.replace("|", ":") schedule_db[weekday]["intervals"].remove(interval) if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + schedule_db[weekday]["intervals"].append(default) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) @router.callback_query(WeekdayCallback.filter(F.action == 'toggle')) async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): + personal_default_interval = chat_db["personal_default_working_time"] + group_default_interval = chat_db["default_working_time"] + default = personal_default_interval if personal_default_interval is not None else group_default_interval weekday = callback_data.weekday schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(DEFAULT_INTERVAL) + schedule_db[weekday]["intervals"].append(default) layout = get_schedule_keyboard(schedule_db) await cb.message.edit_reply_markup(reply_markup=layout) From f0624c2857cde930dffa56bd89ae2e75edc629b7 Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Mon, 1 Jul 2024 20:52:11 +0300 Subject: [PATCH 62/97] feat(db): add fields in db --- bot/constants.py | 12 ++++++++++++ bot/handlers.py | 2 +- bot/intervals.py | 2 +- bot/state.py | 10 +++++++++- 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 648ab2c..39aabb2 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,4 +1,5 @@ from aiogram import html +from bot.intervals import DaySchedule ENCODING = "utf-8" @@ -30,3 +31,14 @@ class AppCommands: } sample_time = "2024-06-03T14:00:00+03:00" + +default_time_zone = "Europe/Moscow" + +days_array = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] + +empty_day_schedule = [DaySchedule() for _ in range(len(days_array))] + +for i in range(len(days_array)): + empty_day_schedule[i].name = days_array[i] + +default_user_schedule = empty_day_schedule diff --git a/bot/handlers.py b/bot/handlers.py index 43e58ee..3454fd9 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -130,7 +130,7 @@ async def set_meetings_time( Please write the meetings time in the {iso8601} format with an offset relative to the UTC time zone. You can calculate the time on the site {time_url}. - + Example: /{set_meetings_time} {sample_time} diff --git a/bot/intervals.py b/bot/intervals.py index 94d54fa..921eb0c 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -141,7 +141,7 @@ def merge_intervals(intervals): class DaySchedule(BaseModel): - name: str + name: str = "" included: bool = True intervals: List[Interval] = [] diff --git a/bot/state.py b/bot/state.py index 5e5f874..cc8895d 100644 --- a/bot/state.py +++ b/bot/state.py @@ -8,11 +8,16 @@ from .chat import ChatId from .language import Language +from .intervals import DaySchedule, Interval +from .constants import default_time_zone, default_user_schedule class ChatUser(BaseModel): username: str = "" is_joined: bool = False + schedule: List[DaySchedule] = default_user_schedule + personal_default_working_time: Optional[Interval] = None + time_zone_shift: int = 0 meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday reminder_period: Optional[int] = None non_replied_daily_msgs: set[int] = set(range(0, 3)) @@ -42,6 +47,8 @@ async def create_user(username: str) -> ChatUser: class ChatState(Document): language: Language = Language.default + time_zone: str = default_time_zone + default_working_time: Optional[Interval] = None meeting_time: Optional[datetime] = None meeting_msg_ids: list[int] = [] topic_id: Optional[int] = None @@ -115,13 +122,14 @@ async def save_state(chat_state: ChatState) -> None: Args: chat_state (ChatState): The chat state instance to save. """ - + await chat_state.save() class UserPM(Document): username: str chat_id: Annotated[ChatId, Indexed(index_type=pymongo.ASCENDING)] + personal_time_zone: str = default_time_zone async def create_user_pm(username: str, chat_id: ChatId) -> UserPM: From 9636cee6293d45c614ebcdec3d3c9017e4cbe3c6 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Wed, 3 Jul 2024 00:50:40 +0300 Subject: [PATCH 63/97] feat(middlewares): add middlewares file and connected them with dispatcher --- bot/bot.py | 3 +++ bot/handlers.py | 8 ++++++++ bot/middlewares.py | 21 +++++++++++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 bot/middlewares.py diff --git a/bot/bot.py b/bot/bot.py index 1d6e3a9..d8cb090 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -14,6 +14,7 @@ from .constants import jobstore from .custom_types import ChatId, SendMessage from .meeting import schedule_meeting +from .middlewares import GroupCommandFilterMiddleware from .settings import Settings from .state import ChatState @@ -62,6 +63,8 @@ async def main(settings: Settings) -> None: async def send_message(chat_id: ChatId, message: str, message_thread_id: Optional[int] = None): return await bot.send_message(chat_id=chat_id, text=message, message_thread_id=message_thread_id) + dp.update.outer_middleware(GroupCommandFilterMiddleware()) + scheduler = init_scheduler(settings=settings) await restore_scheduled_jobs(scheduler=scheduler, send_message=send_message) diff --git a/bot/handlers.py b/bot/handlers.py index 3454fd9..e644247 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -55,6 +55,14 @@ def handle_global_commands( ): @router.message(Command(bot_command_names.start), HasChatState()) async def start(message: Message, chat_state: ChatState): + if message.chat.type == "group": + await message.answer( + "Unfortunately, only supergroups and private chats are supported. " + "Please promote this group to a supergroup by enabling the history of messages for new members " + "or by enabling topics." + ) + return + await get_help(message=message, chat_state=chat_state) # Register user if it is personal message diff --git a/bot/middlewares.py b/bot/middlewares.py new file mode 100644 index 0000000..6f71dcb --- /dev/null +++ b/bot/middlewares.py @@ -0,0 +1,21 @@ +from typing import Any, Callable, Dict, Awaitable + +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject, Update + + +class GroupCommandFilterMiddleware(BaseMiddleware): + async def __call__( + self, + handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], + event: TelegramObject, + data: Dict[str, Any] + ) -> Any: + if isinstance(event, Update) and event.message: + chat = event.message.chat + text = event.message.text + + if chat.type == "group" and not text.startswith("/start"): + return + + return await handler(event, data) From 1b18343fa3f3a226f8673f88a3be5231d0536064 Mon Sep 17 00:00:00 2001 From: fullerite Date: Mon, 1 Jul 2024 20:32:25 +0300 Subject: [PATCH 64/97] feat(topics): store separate chat states for supergroup topics --- bot/filters.py | 4 ++-- bot/state.py | 16 ++++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/bot/filters.py b/bot/filters.py index c2f3505..9f32c35 100644 --- a/bot/filters.py +++ b/bot/filters.py @@ -24,13 +24,13 @@ async def __call__(self, message: Message): class HasChatState(Filter): async def __call__(self, message: Message): - chat_state = await load_state(message.chat.id) + chat_state = await load_state(message.chat.id, message.message_thread_id) return {"chat_state": chat_state} class IsReplyToMeetingMessage(Filter): async def __call__(self, message: Message): - chat_state = await load_state(message.chat.id) + chat_state = await load_state(message.chat.id, message.message_thread_id) if message.reply_to_message: replied_msg_id = message.reply_to_message.message_id diff --git a/bot/state.py b/bot/state.py index cc8895d..c0dada7 100644 --- a/bot/state.py +++ b/bot/state.py @@ -86,34 +86,38 @@ async def get_joined_users(chat_state: ChatState) -> List[ChatUser]: return [user for user in chat_state.users.values() if user.is_joined] -async def create_state(chat_id: ChatId) -> ChatState: +async def create_state(chat_id: ChatId, topic_id: Optional[int] = None) -> ChatState: """Create a new chat state with the given chat ID. Args: chat_id (ChatId): The ID of the chat for which to create a state. + topic_id (int): The ID of the topic associated with the chat state. Returns: ChatState: The newly created chat state instance. """ - return await ChatState(chat_id=chat_id).create() + return await ChatState(chat_id=chat_id, topic_id=topic_id).create() -async def load_state(chat_id: ChatId) -> ChatState: +async def load_state(chat_id: ChatId, topic_id: Optional[int] = None) -> ChatState: """Load a chat state by chat ID or create a new one if not found. Args: chat_id (ChatId): The ID of the chat to load the state for. + topic_id (int): The ID of the topic associated with the chat state. Returns: ChatState: The chat state instance found or created. """ - match chat_state := await ChatState.find_one(ChatState.chat_id == chat_id): + match chat_state := await ChatState.find_one( + {"chat_id": chat_id, "topic_id": topic_id} + ): case ChatState(): return chat_state case _: - return await create_state(chat_id=chat_id) + return await create_state(chat_id=chat_id, topic_id=topic_id) async def save_state(chat_state: ChatState) -> None: @@ -141,4 +145,4 @@ async def load_user_pm(username: str) -> Optional[UserPM]: async def save_user_pm(user_pm: UserPM) -> None: - await user_pm.save() + await user_pm.save() \ No newline at end of file From ddb81c653277c3132dea82642dab3f52f70a1810 Mon Sep 17 00:00:00 2001 From: fullerite Date: Mon, 1 Jul 2024 22:51:35 +0300 Subject: [PATCH 65/97] feat(topics): implement per-topic meeting messages --- bot/bot.py | 1 + bot/handlers.py | 1 + bot/meeting.py | 20 ++++++++++++-------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index d8cb090..e5ad775 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -35,6 +35,7 @@ async def restore_scheduled_jobs( schedule_meeting( meeting_time=chat_state.meeting_time, chat_id=chat_state.chat_id, + topic_id=chat_state.topic_id, scheduler=scheduler, send_message=send_message, ) diff --git a/bot/handlers.py b/bot/handlers.py index e644247..85b9a23 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -108,6 +108,7 @@ async def set_meetings_time( schedule_meeting( meeting_time=meeting_time, chat_id=chat_state.chat_id, + topic_id=topic_id, scheduler=scheduler, send_message=send_message, ) diff --git a/bot/meeting.py b/bot/meeting.py index 679f7cb..09730df 100644 --- a/bot/meeting.py +++ b/bot/meeting.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Optional from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -10,9 +11,8 @@ from .state import load_state, save_state, get_joined_users -async def send_meeting_messages(chat_id: ChatId, send_message: SendMessage): - chat_state = await load_state(chat_id=chat_id) - topic_id = chat_state.topic_id +async def send_meeting_messages(chat_id: ChatId, topic_id: Optional[int], send_message: SendMessage): + chat_state = await load_state(chat_id=chat_id, topic_id=topic_id) current_day = datetime.now().weekday() await send_message(chat_id=chat_id, message=_("Meeting time!"), message_thread_id=topic_id) @@ -42,22 +42,26 @@ async def send_meeting_messages(chat_id: ChatId, send_message: SendMessage): await save_state(chat_state=chat_state) -def make_job_id(some_id: int): - return str(some_id) + "_meeting" +def make_job_id(chat_id: int, topic_id: Optional[int]): + if topic_id: + return f"{chat_id}_{topic_id}_meeting" + else: + return f"{chat_id}_meeting" def schedule_meeting( meeting_time: datetime, chat_id: ChatId, + topic_id: Optional[int], scheduler: AsyncIOScheduler, send_message: SendMessage, ): scheduler.add_job( jobstore=jobstore, func=send_meeting_messages, - id=make_job_id(chat_id), + id=make_job_id(chat_id, topic_id), replace_existing=True, - kwargs={"chat_id": chat_id, "send_message": send_message}, + kwargs={"chat_id": chat_id, "topic_id": topic_id, "send_message": send_message}, trigger="cron", start_date=meeting_time, hour=meeting_time.hour, @@ -67,4 +71,4 @@ def schedule_meeting( misfire_grace_time=42, ) - logging.info(scheduler.get_job(make_job_id(chat_id))) + logging.info(scheduler.get_job(make_job_id(chat_id, topic_id))) From 9a44fce2b9bc661bb7fb869f4b259ff5c5c1f0a4 Mon Sep 17 00:00:00 2001 From: fullerite Date: Mon, 1 Jul 2024 22:55:17 +0300 Subject: [PATCH 66/97] refactor(typing): adjust topic_id type hint --- bot/state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/state.py b/bot/state.py index c0dada7..245c483 100644 --- a/bot/state.py +++ b/bot/state.py @@ -86,7 +86,7 @@ async def get_joined_users(chat_state: ChatState) -> List[ChatUser]: return [user for user in chat_state.users.values() if user.is_joined] -async def create_state(chat_id: ChatId, topic_id: Optional[int] = None) -> ChatState: +async def create_state(chat_id: ChatId, topic_id: Optional[int]) -> ChatState: """Create a new chat state with the given chat ID. Args: @@ -100,7 +100,7 @@ async def create_state(chat_id: ChatId, topic_id: Optional[int] = None) -> ChatS return await ChatState(chat_id=chat_id, topic_id=topic_id).create() -async def load_state(chat_id: ChatId, topic_id: Optional[int] = None) -> ChatState: +async def load_state(chat_id: ChatId, topic_id: Optional[int]) -> ChatState: """Load a chat state by chat ID or create a new one if not found. Args: From ebd81c7e759f0e01f936a070a177f3904e70a416 Mon Sep 17 00:00:00 2001 From: fullerite Date: Mon, 1 Jul 2024 23:28:15 +0300 Subject: [PATCH 67/97] feat(topics): send reminders w.r.t topics --- .gitignore | 1 + .vscode/settings.json | 2 +- bot/reminder.py | 23 +++++++++++++++++------ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index d7dd798..8052169 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ data __pycache__ node_modules .idea +.venv \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index fc4c9b2..e3ec487 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,5 @@ } }, "terminal.integrated.scrollback": 100000, - "workbench.sideBar.location": "right" + "python.analysis.typeCheckingMode": "basic", } \ No newline at end of file diff --git a/bot/reminder.py b/bot/reminder.py index 08bd59b..6c90f07 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -16,10 +16,15 @@ async def send_reminder_messages( - meeting_chat_id: ChatId, username: str, user_chat_id: ChatId, send_message: SendMessage, bot: Bot + meeting_chat_id: ChatId, + meeting_topic_id: Optional[int], + username: str, + user_chat_id: ChatId, + send_message: SendMessage, + bot: Bot ): chat = await bot.get_chat(meeting_chat_id) - chat_state = await load_state(chat_id=meeting_chat_id) + chat_state = await load_state(chat_id=meeting_chat_id, topic_id=meeting_topic_id) user = await get_user(chat_state=chat_state, username=username) have_to_reply = list(user.non_replied_daily_msgs) @@ -110,13 +115,17 @@ async def update_reminders( user_chad_id=user_pm.chat_id, meeting_time=chat.meeting_time, meeting_chat_id=chat.chat_id, + meeting_topic_id=chat.topic_id, scheduler=scheduler, send_message=send_message ) -def make_job_id(user_chat_id: int, meeting_chat_id: int): - return str(user_chat_id) + "_reminder" + "_for_" + str(meeting_chat_id) +def make_job_id(user_chat_id: int, meeting_chat_id: int, meeting_topic_id: Optional[int]): + if meeting_topic_id: + return f"{user_chat_id}_reminder_for_{meeting_chat_id}_{meeting_topic_id}" + else: + return f"{user_chat_id}_reminder_for_{meeting_chat_id}" def schedule_reminder( @@ -126,16 +135,18 @@ def schedule_reminder( user_chad_id: ChatId, meeting_time: datetime, meeting_chat_id: ChatId, + meeting_topic_id: Optional[int], scheduler: AsyncIOScheduler, send_message: SendMessage, ): scheduler.add_job( jobstore=jobstore, func=send_reminder_messages, - id=make_job_id(user_chad_id, meeting_chat_id), + id=make_job_id(user_chad_id, meeting_chat_id, meeting_topic_id), replace_existing=True, kwargs={ "meeting_chat_id": meeting_chat_id, + "meeting_topic_id": meeting_topic_id, "username": username, "user_chat_id": user_chad_id, "send_message": send_message, @@ -147,7 +158,7 @@ def schedule_reminder( misfire_grace_time=42, ) - logging.info(scheduler.get_job(make_job_id(user_chad_id, meeting_chat_id))) + logging.info(scheduler.get_job(make_job_id(user_chad_id, meeting_chat_id, meeting_topic_id))) def get_message_link(chat_id: ChatId, message_id: ChatId, thread_id: Optional[int], chat_type: str): From c55e21963599a37e6cfb3b63de88108d7be749d9 Mon Sep 17 00:00:00 2001 From: Fullerite <99542745+Fullerite@users.noreply.github.com> Date: Tue, 2 Jul 2024 15:34:32 +0300 Subject: [PATCH 68/97] fix(configs): revert accidental change in settings configuration file --- .vscode/settings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index e3ec487..288f5f9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -41,5 +41,5 @@ } }, "terminal.integrated.scrollback": 100000, - "python.analysis.typeCheckingMode": "basic", -} \ No newline at end of file + "workbench.sideBar.location": "right" +} From 54cf42857071333a106722e1a43cc777ffec5818 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Wed, 3 Jul 2024 18:34:11 +0300 Subject: [PATCH 69/97] feat(intervals): edit Interval class to use datetime instead of datetime.time --- bot/intervals.py | 53 +++++++++++++++++++++++++----------------------- bot/state.py | 3 +-- bot/work_time.py | 3 +-- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 921eb0c..d0fdded 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -12,7 +12,7 @@ class IntervalException(Exception): class InvalidTimeFormatException(IntervalException): """Raised when the time format is invalid""" - def __init__(self, time_str, message="Time format must be either HH:MM or HH.MM"): + def __init__(self, time_str: str, message="Time format must be either HH:MM or HH.MM"): self.time_str = time_str self.message = message super().__init__(self.message) @@ -20,26 +20,29 @@ def __init__(self, time_str, message="Time format must be either HH:MM or HH.MM" class InvalidIntervalException(IntervalException): """Raised when the interval is invalid""" - def __init__(self, start_time, end_time, message="Start time must be earlier than end time"): - self.start_time = start_time - self.end_time = end_time - self.message = message + def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time"): + self.start_time: time = start_time + self.end_time: time = end_time + self.message: str = message super().__init__(self.message) +IMMUTABLE_DATE = datetime(year=2024, month=1, day=1) + + class Interval(BaseModel): - start_time: time - end_time: time + start_time: datetime + end_time: datetime tz: str = "UTC" @computed_field @property - def start_time_utc(self) -> time: + def start_time_utc(self) -> datetime: return self.convert_to_utc(self.start_time) @computed_field @property - def end_time_utc(self) -> time: + def end_time_utc(self) -> datetime: return self.convert_to_utc(self.end_time) @field_validator("tz") @@ -49,19 +52,19 @@ def zone_must_be_valid(cls, tz: str): except UnknownTimeZoneError: raise ValueError("You should pass valid zone name") - def convert_to_utc(self, local_time: time) -> time: - local_dt = datetime.combine(datetime.today(), local_time).replace(tzinfo=timezone(self.tz)) + def convert_to_utc(self, local_time: datetime) -> datetime: + local_dt = local_time.replace(tzinfo=timezone(self.tz)) utc_dt = local_dt.astimezone(utc) - return utc_dt.time() + return utc_dt @classmethod def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') - start_time = cls.parse_time(start_str) - end_time = cls.parse_time(end_str) + start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str)) + end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str)) if start_time >= end_time: - raise InvalidIntervalException(start_time, end_time) + raise InvalidIntervalException(start_time.time(), end_time.time()) return cls(start_time=start_time, end_time=end_time, tz=tz) @@ -78,11 +81,11 @@ def parse_time(time_str: str) -> time: raise InvalidTimeFormatException(time_str) def convert_to_timezone(self, new_tz: str): - start_dt = datetime.combine(datetime.today(), self.start_time).replace(tzinfo=timezone(self.tz)) - end_dt = datetime.combine(datetime.today(), self.end_time).replace(tzinfo=timezone(self.tz)) + start_dt = self.start_time.replace(tzinfo=timezone(self.tz)) + end_dt = self.end_time.replace(tzinfo=timezone(self.tz)) new_start_dt = start_dt.astimezone(timezone(new_tz)) new_end_dt = end_dt.astimezone(timezone(new_tz)) - return Interval(start_time=new_start_dt.time(), end_time=new_end_dt.time(), tz=new_tz) + return Interval(start_time=new_start_dt, end_time=new_end_dt, tz=new_tz) def to_string(self): return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" @@ -103,10 +106,10 @@ def __repr__(self): return f"Interval({self.to_string()}, tz={self.tz})" def overlaps_with(self, other): - start_a = self.start_time_utc - end_a = self.end_time_utc - start_b = other.start_time_utc - end_b = other.end_time_utc + start_a = self.start_time_utc.time() + end_a = self.end_time_utc.time() + start_b = other.start_time_utc.time() + end_b = other.end_time_utc.time() return max(start_a, start_b) < min(end_a, end_b) @@ -121,13 +124,13 @@ def merge_intervals(intervals): return [] # Sort intervals by start time - sorted_intervals = sorted(intervals, key=lambda x: x.start_time) + sorted_intervals = sorted(intervals, key=lambda x: x.start_time.time()) merged_intervals = [sorted_intervals[0]] for current in sorted_intervals[1:]: last = merged_intervals[-1] - if current.overlaps_with(last) or current.start_time <= last.end_time: + if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): # Merge intervals merged_intervals[-1] = Interval( start_time=min(last.start_time, current.start_time), @@ -152,4 +155,4 @@ def add_interval(self, interval: Interval): self.intervals.append(interval) def remove_interval(self, interval: Interval): - self.intervals = [i for i in self.intervals if i != interval] + self.intervals.remove(interval) diff --git a/bot/state.py b/bot/state.py index 245c483..2d10aff 100644 --- a/bot/state.py +++ b/bot/state.py @@ -1,6 +1,5 @@ from datetime import datetime from typing import Annotated, Optional, Dict, List -from zoneinfo import ZoneInfo import pymongo from beanie import Document, Indexed @@ -145,4 +144,4 @@ async def load_user_pm(username: str) -> Optional[UserPM]: async def save_user_pm(user_pm: UserPM) -> None: - await user_pm.save() \ No newline at end of file + await user_pm.save() diff --git a/bot/work_time.py b/bot/work_time.py index 2230c08..f9629b6 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -4,7 +4,6 @@ from aiogram import Bot, Router, F from aiogram.filters.command import Command -from aiogram.fsm.context import FSMContext from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -75,7 +74,7 @@ def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message(Command(bot_command_names.set_default_working_time), HasMessageText()) + @router.message(Command(bot_command_names.set_default_working_time), HasMessageText(), HasMessageUserUsername()) async def set_default_working_time(message: Message, message_text: str): cmd = "/" + bot_command_names.set_default_working_time interval = message_text.replace(cmd, "").strip() From 8d69cb1b23ca20ce395f171b6a0600d7f2f9beff Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 5 Jul 2024 10:41:46 +0300 Subject: [PATCH 70/97] feat(intervals): modified merge intervals function to merge only unique intervals --- bot/intervals.py | 95 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index d0fdded..520057c 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,8 +1,10 @@ from typing import List +from collections import Counter from datetime import datetime, time +from zoneinfo import ZoneInfo from pydantic import BaseModel, computed_field, field_validator -from pytz import timezone, utc, UnknownTimeZoneError +from pytz import timezone, UnknownTimeZoneError class IntervalException(Exception): @@ -21,8 +23,8 @@ def __init__(self, time_str: str, message="Time format must be either HH:MM or H class InvalidIntervalException(IntervalException): """Raised when the interval is invalid""" def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time"): - self.start_time: time = start_time - self.end_time: time = end_time + self.start_time: str = start_time.strftime("%H:%M") + self.end_time: str = end_time.strftime("%H:%M") self.message: str = message super().__init__(self.message) @@ -51,17 +53,18 @@ def zone_must_be_valid(cls, tz: str): timezone(tz) except UnknownTimeZoneError: raise ValueError("You should pass valid zone name") + return tz - def convert_to_utc(self, local_time: datetime) -> datetime: - local_dt = local_time.replace(tzinfo=timezone(self.tz)) - utc_dt = local_dt.astimezone(utc) + @staticmethod + def convert_to_utc(local_time: datetime) -> datetime: + utc_dt = local_time.astimezone(ZoneInfo("UTC")) return utc_dt @classmethod def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') - start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str)) - end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str)) + start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str), ZoneInfo(tz)) + end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str), ZoneInfo(tz)) if start_time >= end_time: raise InvalidIntervalException(start_time.time(), end_time.time()) @@ -81,10 +84,8 @@ def parse_time(time_str: str) -> time: raise InvalidTimeFormatException(time_str) def convert_to_timezone(self, new_tz: str): - start_dt = self.start_time.replace(tzinfo=timezone(self.tz)) - end_dt = self.end_time.replace(tzinfo=timezone(self.tz)) - new_start_dt = start_dt.astimezone(timezone(new_tz)) - new_end_dt = end_dt.astimezone(timezone(new_tz)) + new_start_dt = self.start_time.astimezone(ZoneInfo(new_tz)) + new_end_dt = self.end_time.astimezone(ZoneInfo(new_tz)) return Interval(start_time=new_start_dt, end_time=new_end_dt, tz=new_tz) def to_string(self): @@ -126,33 +127,67 @@ def merge_intervals(intervals): # Sort intervals by start time sorted_intervals = sorted(intervals, key=lambda x: x.start_time.time()) - merged_intervals = [sorted_intervals[0]] - for current in sorted_intervals[1:]: - last = merged_intervals[-1] - - if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): - # Merge intervals - merged_intervals[-1] = Interval( - start_time=min(last.start_time, current.start_time), - end_time=max(last.end_time, current.end_time), - tz=last.tz - ) + print("Debug:\n") + for i in sorted_intervals: + print(i.to_string(), i.start_time, i.end_time, i.tz, "\n") + + # Exclude repeating intervals + counter = Counter(sorted_intervals) + print("Debug:\n") + print(counter) + unique = [] + n_unique = [] + for interval in sorted_intervals: + if counter[interval] == 1: + unique.append(interval) else: - merged_intervals.append(current) + n_unique.append(interval) + + # Merge unique intervals + merged_intervals = [] + if len(unique) > 0: + merged_intervals.append(unique[0]) + for current in unique[1:]: + last = merged_intervals[-1] + + if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): + # Merge intervals + merged_intervals[-1] = Interval( + start_time=min(last.start_time, current.start_time), + end_time=max(last.end_time, current.end_time), + tz=last.tz + ) + else: + merged_intervals.append(current) + + return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time.time())) + - return merged_intervals +DEFAULT_INTERVAL = Interval.from_string("9:00 - 17:00", "Europe/Moscow") class DaySchedule(BaseModel): - name: str = "" - included: bool = True + name: str + included: bool = False intervals: List[Interval] = [] - def toggle_inclusion(self): + def toggle_inclusion(self) -> None: self.included = not self.included + if self.included and len(self.intervals) == 0: + self.add_interval(DEFAULT_INTERVAL) - def add_interval(self, interval: Interval): + def add_interval(self, interval: Interval) -> None: self.intervals.append(interval) + self.intervals = Interval.merge_intervals(self.intervals) - def remove_interval(self, interval: Interval): + def remove_interval(self, interval: Interval) -> None: self.intervals.remove(interval) + if len(self.intervals) == 0 and self.included: + self.toggle_inclusion() + + @staticmethod + def is_workday(day: str) -> bool: + return day not in {"Saturday", "Sunday"} + + def __hash__(self): + return hash(self.name) From 67438bdcc624cbe9a691dac91ac5305d70e6bbf2 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:08:55 +0300 Subject: [PATCH 71/97] feat(fsm): add file for storing custom FSM states --- bot/fsm_states.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 bot/fsm_states.py diff --git a/bot/fsm_states.py b/bot/fsm_states.py new file mode 100644 index 0000000..e69de29 From ae4e2bbf33bcecee7f046083e17c5a957e918931 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:11:33 +0300 Subject: [PATCH 72/97] feat(callbacks): change default callback separator symbol from ':' to '|' to be able to work with intervals --- bot/callbacks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/callbacks.py b/bot/callbacks.py index f3b2364..4cf7b28 100644 --- a/bot/callbacks.py +++ b/bot/callbacks.py @@ -1,7 +1,7 @@ from aiogram.filters.callback_data import CallbackData -class IntervalCallback(CallbackData, prefix="interval"): +class IntervalCallback(CallbackData, prefix="interval", sep="|"): weekday: str interval: str action: str # add, remove, edit From 1aa70f1c06f1d4ef293ad71b040e1d817c795af2 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:12:51 +0300 Subject: [PATCH 73/97] feat(constants): edit empty schedule to be dictionary instead of list --- bot/constants.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 39aabb2..9fb7b86 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,5 +1,6 @@ -from aiogram import html -from bot.intervals import DaySchedule +from datetime import datetime + +from .intervals import DaySchedule ENCODING = "utf-8" @@ -36,9 +37,6 @@ class AppCommands: days_array = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] -empty_day_schedule = [DaySchedule() for _ in range(len(days_array))] - -for i in range(len(days_array)): - empty_day_schedule[i].name = days_array[i] +empty_schedule = {day: DaySchedule(name=day) for day in days_array} -default_user_schedule = empty_day_schedule +default_user_schedule = empty_schedule From ea768bcc5c745098447ebd8463b0165e35f08dc9 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:14:03 +0300 Subject: [PATCH 74/97] feat(db): edit mongodb config to return datetime in offset-aware state --- bot/db.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/db.py b/bot/db.py index aa6be59..8d6e893 100644 --- a/bot/db.py +++ b/bot/db.py @@ -10,7 +10,8 @@ async def main(settings: Settings): client = AsyncIOMotorClient( # TODO enable user and password # f"mongodb://{settings.mongo_username}:{settings.mongo_password}@{settings.mongo_host}:{settings.mongo_port}" - f"mongodb://{settings.mongo_host}:{settings.mongo_port}" + f"mongodb://{settings.mongo_host}:{settings.mongo_port}", + tz_aware=True ) db = client["bot_states"] From 2476d8cbe65b7f674aee2d2e2eb73000fd0032f3 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:14:51 +0300 Subject: [PATCH 75/97] feat(filters): edit filters to work with callbacks --- bot/filters.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/bot/filters.py b/bot/filters.py index 9f32c35..8b804c6 100644 --- a/bot/filters.py +++ b/bot/filters.py @@ -1,5 +1,5 @@ from aiogram.filters import Filter -from aiogram.types import Message, User +from aiogram.types import Message, CallbackQuery, User from .state import load_state @@ -13,8 +13,8 @@ async def __call__(self, message: Message): class HasMessageUserUsername(Filter): - async def __call__(self, message: Message): - match user := message.from_user: + async def __call__(self, obj: Message | CallbackQuery): + match user := obj.from_user: case User(): match user.username: case str(): @@ -23,9 +23,14 @@ async def __call__(self, message: Message): class HasChatState(Filter): - async def __call__(self, message: Message): - chat_state = await load_state(message.chat.id, message.message_thread_id) - return {"chat_state": chat_state} + async def __call__(self, obj: Message | CallbackQuery): + match obj: + case Message(): + chat_state = await load_state(obj.chat.id, obj.message_thread_id) + return {"chat_state": chat_state} + case CallbackQuery(): + chat_state = await load_state(obj.message.chat.id, obj.message.message_thread_id) + return {"chat_state": chat_state} class IsReplyToMeetingMessage(Filter): From 9d689035e5bb9d8f76ae7dfc066bd83818e890dc Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:15:38 +0300 Subject: [PATCH 76/97] feat(fsm): add group of states for interval editing --- bot/fsm_states.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/fsm_states.py b/bot/fsm_states.py index e69de29..11ca2b9 100644 --- a/bot/fsm_states.py +++ b/bot/fsm_states.py @@ -0,0 +1,5 @@ +from aiogram.filters.state import StatesGroup, State + + +class IntervalEditingState(StatesGroup): + EnterNewInterval = State() From fbf55caf31426db0e3e8384ff6ff6288de7a5f0e Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:16:50 +0300 Subject: [PATCH 77/97] feat(intervals): edit interval model to store times in UTC --- bot/intervals.py | 74 ++++++++++++++++++++++-------------------------- 1 file changed, 34 insertions(+), 40 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 520057c..007fb27 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,9 +1,9 @@ -from typing import List +from typing import List, Tuple from collections import Counter from datetime import datetime, time from zoneinfo import ZoneInfo -from pydantic import BaseModel, computed_field, field_validator +from pydantic import BaseModel, field_validator from pytz import timezone, UnknownTimeZoneError @@ -33,20 +33,10 @@ def __init__(self, start_time: time, end_time: time, message: str = "Start time class Interval(BaseModel): - start_time: datetime - end_time: datetime + start_time_utc: datetime + end_time_utc: datetime tz: str = "UTC" - @computed_field - @property - def start_time_utc(self) -> datetime: - return self.convert_to_utc(self.start_time) - - @computed_field - @property - def end_time_utc(self) -> datetime: - return self.convert_to_utc(self.end_time) - @field_validator("tz") def zone_must_be_valid(cls, tz: str): try: @@ -55,11 +45,6 @@ def zone_must_be_valid(cls, tz: str): raise ValueError("You should pass valid zone name") return tz - @staticmethod - def convert_to_utc(local_time: datetime) -> datetime: - utc_dt = local_time.astimezone(ZoneInfo("UTC")) - return utc_dt - @classmethod def from_string(cls, interval_str: str, tz: str = "UTC"): start_str, end_str = interval_str.replace(" ", "").split('-') @@ -69,7 +54,15 @@ def from_string(cls, interval_str: str, tz: str = "UTC"): if start_time >= end_time: raise InvalidIntervalException(start_time.time(), end_time.time()) - return cls(start_time=start_time, end_time=end_time, tz=tz) + start_time_utc = cls.convert_to_utc(start_time) + end_time_utc = cls.convert_to_utc(end_time) + + return cls(start_time_utc=start_time_utc, end_time_utc=end_time_utc, tz=tz) + + @staticmethod + def convert_to_utc(local_time: datetime) -> datetime: + utc_dt = local_time.astimezone(ZoneInfo("UTC")) + return utc_dt @staticmethod def parse_time(time_str: str) -> time: @@ -83,13 +76,19 @@ def parse_time(time_str: str) -> time: except ValueError: raise InvalidTimeFormatException(time_str) - def convert_to_timezone(self, new_tz: str): - new_start_dt = self.start_time.astimezone(ZoneInfo(new_tz)) - new_end_dt = self.end_time.astimezone(ZoneInfo(new_tz)) - return Interval(start_time=new_start_dt, end_time=new_end_dt, tz=new_tz) + def convert_to_timezone(self, new_tz: str) -> Tuple[datetime, datetime]: + new_start_dt = self.start_time_utc.astimezone(ZoneInfo(new_tz)) + new_end_dt = self.end_time_utc.astimezone(ZoneInfo(new_tz)) + return new_start_dt, new_end_dt + + def to_string(self, tz: str = "UTC"): + if tz == "UTC": + start_time = self.start_time_utc + end_time = self.end_time_utc + else: + start_time, end_time = self.convert_to_timezone(new_tz=tz) - def to_string(self): - return f"{self.start_time.strftime('%H:%M')} - {self.end_time.strftime('%H:%M')}" + return f"{start_time.strftime('%H:%M')} - {end_time.strftime('%H:%M')}" def __hash__(self): return hash((self.start_time_utc, self.end_time_utc)) @@ -125,16 +124,10 @@ def merge_intervals(intervals): return [] # Sort intervals by start time - sorted_intervals = sorted(intervals, key=lambda x: x.start_time.time()) - - print("Debug:\n") - for i in sorted_intervals: - print(i.to_string(), i.start_time, i.end_time, i.tz, "\n") + sorted_intervals = sorted(intervals, key=lambda x: x.start_time_utc.time()) # Exclude repeating intervals counter = Counter(sorted_intervals) - print("Debug:\n") - print(counter) unique = [] n_unique = [] for interval in sorted_intervals: @@ -150,17 +143,17 @@ def merge_intervals(intervals): for current in unique[1:]: last = merged_intervals[-1] - if current.overlaps_with(last) or current.start_time.time() <= last.end_time.time(): + if current.overlaps_with(last) or current.start_time_utc.time() <= last.end_time_utc.time(): # Merge intervals merged_intervals[-1] = Interval( - start_time=min(last.start_time, current.start_time), - end_time=max(last.end_time, current.end_time), + start_time_utc=min(last.start_time_utc, current.start_time_utc), + end_time_utc=max(last.end_time_utc, current.end_time_utc), tz=last.tz ) else: merged_intervals.append(current) - return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time.time())) + return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time_utc.time())) DEFAULT_INTERVAL = Interval.from_string("9:00 - 17:00", "Europe/Moscow") @@ -180,10 +173,11 @@ def add_interval(self, interval: Interval) -> None: self.intervals.append(interval) self.intervals = Interval.merge_intervals(self.intervals) - def remove_interval(self, interval: Interval) -> None: + def remove_interval(self, interval: Interval, ignore_inclusion=False) -> None: self.intervals.remove(interval) - if len(self.intervals) == 0 and self.included: - self.toggle_inclusion() + if not ignore_inclusion: + if len(self.intervals) == 0 and self.included: + self.toggle_inclusion() @staticmethod def is_workday(day: str) -> bool: From 658dfdc923b6477c55f086b5b96b7bb299927f4d Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:18:06 +0300 Subject: [PATCH 78/97] feat(keyboards): modify keyboards for new callbacks and intervals --- bot/keyboards.py | 56 +++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/bot/keyboards.py b/bot/keyboards.py index a8fcaca..44e925a 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -1,6 +1,10 @@ +from typing import Dict + from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton from .callbacks import IntervalCallback, WeekdayCallback +from .intervals import Interval, DaySchedule, DEFAULT_INTERVAL +from .constants import days_array INCLUDED_1 = "โœ…" INCLUDED_2 = "๐Ÿ—น" @@ -12,55 +16,53 @@ REMOVE = "โœ–๏ธ" -def get_interval_keyboard(interval: str, weekday: str) -> InlineKeyboardBuilder: +def get_interval_keyboard(interval: Interval, weekday: str, tz: str) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() - interval_std = interval.replace(":", "|") + interval_str = interval.to_string(tz=tz) + default_interval_str = DEFAULT_INTERVAL.to_string(tz=tz) - builder.button(text=interval, switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}") - builder.button(text=REMOVE, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='remove')) - builder.button(text=ADD, callback_data=IntervalCallback(weekday=weekday, interval=interval_std, action='add')) + builder.button( + text=interval_str, + callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='edit') + ) + builder.button( + text=REMOVE, + callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='remove') + ) + builder.button( + text=ADD, + callback_data=IntervalCallback(weekday=weekday, interval=default_interval_str, action='add') + ) builder.adjust(3) return builder -def get_weekday_keyboard(weekday: str, content: dict) -> InlineKeyboardBuilder: +def get_weekday_keyboard(weekday: DaySchedule, tz: str) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() - included_text = INCLUDED_3 if content["include"] else NOT_INCLUDED_3 + included_text = INCLUDED_3 if weekday.included else NOT_INCLUDED_3 - day = InlineKeyboardButton(text=f"{weekday}", callback_data="#") + day = InlineKeyboardButton(text=f"{weekday.name}", callback_data="#") status = InlineKeyboardButton( text=included_text, - callback_data=WeekdayCallback(weekday=weekday, action="toggle").pack() + callback_data=WeekdayCallback(weekday=weekday.name, action="toggle").pack() ) builder.row(day, status) - if content["include"]: - for interval in content["intervals"]: - interval_builder = get_interval_keyboard(interval, weekday) + if weekday.included: + for interval in weekday.intervals: + interval_builder = get_interval_keyboard(interval, weekday.name, tz) builder.attach(interval_builder) return builder -def get_schedule_keyboard(week_schedule: dict) -> InlineKeyboardMarkup: +def get_schedule_keyboard(week_schedule: Dict[str, DaySchedule], tz: str) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() - for weekday, content in week_schedule.items(): - weekday_builder = get_weekday_keyboard(weekday, content) + for weekday in days_array: + weekday_builder = get_weekday_keyboard(week_schedule[weekday], tz) builder.attach(weekday_builder) return builder.as_markup() - - -def get_interval_error_keyboard(interval: str, weekday: str) -> InlineKeyboardMarkup: - builder = InlineKeyboardBuilder() - - builder.button(text="Cancel", callback_data="cancel_editing_interval") - builder.button( - text="Enter the interval again", - switch_inline_query_current_chat=f"edit {weekday} {interval} --> {interval}" - ) - - return builder.adjust(2).as_markup() From efc42b88007fbcfee43f0792a4be98172d007535 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:19:16 +0300 Subject: [PATCH 79/97] feat(db): add fields to chat user document to handle interval editing --- bot/state.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/state.py b/bot/state.py index 2d10aff..d1dd441 100644 --- a/bot/state.py +++ b/bot/state.py @@ -14,13 +14,19 @@ class ChatUser(BaseModel): username: str = "" is_joined: bool = False - schedule: List[DaySchedule] = default_user_schedule + schedule: Dict[str, DaySchedule] = default_user_schedule personal_default_working_time: Optional[Interval] = None time_zone_shift: int = 0 meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday reminder_period: Optional[int] = None non_replied_daily_msgs: set[int] = set(range(0, 3)) + # TODO: relocate these fields to cache (Redis for example) + schedule_msg: Optional[int] = None + to_delete_msg_ids: set[int] = set() + to_edit_weekday: Optional[str] = None + to_edit_interval: Optional[str] = None + def __hash__(self): return hash(self.username) From ffb0109152fcd39c458f18e15df7b55fb9764f06 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:20:18 +0300 Subject: [PATCH 80/97] feat(messages): edit error messages to more use friendly form --- bot/messages.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/messages.py b/bot/messages.py index de8e84c..3be1a6d 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -60,20 +60,22 @@ def make_daily_messages(usernames: str) -> List[str]: def make_interval_validation_message(interval_str: str, tz: str) -> Tuple[bool, str]: + new = "Please, reenter interval." try: interval = Interval.from_string(interval_str=interval_str, tz=tz) - msg = _("Successfully parsed interval: {interval}").format(interval=interval) + msg = _("Successfully parsed interval: {interval}\n{new}").format(interval=interval, new=new) return True, msg except InvalidTimeFormatException as e: - msg = _("Error: Invalid time format for '{time}'. {msg}").format(time=e.time_str, msg=e.message) + msg = _("Invalid time format for '{time}'. {msg}\n{new}").format(time=e.time_str, msg=e.message, new=new) return False, msg except InvalidIntervalException as e: - msg = _("Error: {msg} (start: {start}, end: {end})").format( + msg = _("{msg} (start: {start}, end: {end})\n{new}").format( msg=e.message, start=e.start_time, - end=e.end_time + end=e.end_time, + new=new ) return False, msg except Exception as e: - msg = _("Error: An unexpected error occurred. {error}").format(error=str(e)) + msg = _("An unexpected error occurred. {error}").format(error=str(e)) return False, msg From 14a8f53816777922aed4b509f511f6dd1b10e1cd Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 6 Jul 2024 01:21:39 +0300 Subject: [PATCH 81/97] feat(schedule): connect all operations with mongodb and rework interval editng --- bot/work_time.py | 369 +++++++++++++++++++---------------------------- 1 file changed, 149 insertions(+), 220 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index f9629b6..d1a0d74 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -1,10 +1,11 @@ import re -from textwrap import dedent -from datetime import datetime +import asyncio from aiogram import Bot, Router, F from aiogram.filters.command import Command -from aiogram.types import Message, InlineQuery, CallbackQuery, InlineQueryResultArticle, InputTextMessageContent +from aiogram.types import Message, CallbackQuery +from aiogram.fsm.context import FSMContext +from aiogram.utils import markdown as fmt from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -12,255 +13,183 @@ from .custom_types import SendMessage from .messages import make_interval_validation_message from .callbacks import IntervalCallback, WeekdayCallback -from .constants import interval_format, sample_interval +from .fsm_states import IntervalEditingState from .intervals import Interval -from .filters import HasChatState, HasMessageUserUsername, HasMessageText -from .keyboards import get_schedule_keyboard, get_interval_error_keyboard -from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm - - -EDIT_HANDLE_IVL = \ - r'^edit\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\s*-->\s*\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' - -EDIT_PARSE_IVL = \ - r'^edit\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})\s*-->' \ - r'\s*(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' - -ADD_HANDLE_IVL = r'^add\s+\w+\s+\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}$' -ADD_IVL = r'^add\s+(?P\w+)\s+(?P\d{1,2}[:.]\d{2})\s*-\s*(?P\d{1,2}[:.]\d{2})$' - -schedule_db = { - "Monday": { - "include": True, - "intervals": ["9:00 - 18:00"] - }, - "Tuesday": { - "include": False, - "intervals": [] - }, - "Wednesday": { - "include": True, - "intervals": ["10:00 - 17:00"] - }, - "Thursday": { - "include": False, - "intervals": [] - }, - "Friday": { - "include": True, - "intervals": ["9:00 - 18:00"] - }, - "Saturday": { - "include": False, - "intervals": [] - }, - "Sunday": { - "include": False, - "intervals": [] - } - } - -chat_db = { - "default_working_time": "9:00 - 17:00", - "personal_default_working_time": "8:00 - 18:00", - "schedule_msg": None, - "tz": "Europe/Moscow", - "shift": 0, - "edit_interval_message": None, - "message_with_keyboard": None -} +from .filters import HasChatState, HasMessageUserUsername +from .keyboards import get_schedule_keyboard +from .state import ChatState, save_state, get_user, load_user_pm + + +INTERVAL_PATTERN = r"\b\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\b" + +# TODO: Apply tz validation in Interval class +# TODO: Handle two scenarios for timezone changing via shift and write logic for intervals for those cases +# TODO: Check code and messages by scenario: https://github.com/team-work-tools/team-work-telegram-bot/pull/106 +# TODO: Write logic for setting group schedule: https://t.me/c/2215513034/6/2076, https://t.me/c/2215513034/6/2083 def handle_working_time( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message(Command(bot_command_names.set_default_working_time), HasMessageText(), HasMessageUserUsername()) - async def set_default_working_time(message: Message, message_text: str): - cmd = "/" + bot_command_names.set_default_working_time - interval = message_text.replace(cmd, "").strip() - - is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") - if is_valid: - chat_db["default_working_time"] = interval - await message.answer( - _( - "Your group default working time is set to {interval}" - ).format(interval=interval) - ) - else: - await message.answer( - dedent( - _( - f""" - Please write group default working time in {interval_format} format. - - Example: - - /{set_default_working_time} {sample_interval} - """ - ).format( - interval_format=interval_format, - set_default_working_time=bot_command_names.set_default_working_time, - sample_interval=sample_interval - ) - ) - ) - - @router.message(Command(bot_command_names.set_personal_default_working_time), HasMessageText()) - async def set_personal_default_working_time(message: Message, message_text: str): - cmd = "/" + bot_command_names.set_personal_default_working_time - interval = message_text.replace(cmd, "").strip() - - is_valid, status_msg = make_interval_validation_message(interval_str=interval, tz="UTC") - if is_valid: - chat_db["personal_default_working_time"] = interval - await message.answer( - _( - "Your personal default working time is set to {interval}" - ).format(interval=interval) - ) - else: - await message.answer( - dedent( - _( - f""" - Please write your personal default working time in {interval_format} format. - - Example: - - /{set_personal_default_working_time} {sample_interval} - """ - ).format( - interval_format=interval_format, - set_default_working_time=bot_command_names.set_personal_default_working_time, - sample_interval=sample_interval - ) - ) - ) - @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) async def show_user_schedule(message: Message, username: str, chat_state: ChatState): - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - - if personal_default_interval is not None or group_default_interval is not None: - default = personal_default_interval if personal_default_interval is not None else group_default_interval - - for weekday in schedule_db: - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(default) - - layout = get_schedule_keyboard(schedule_db) - schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) - chat_db["schedule_msg"] = schedule_msg - else: - await message.answer("You should set up default group or personal working time first!") - - @router.inline_query(F.query.regexp(EDIT_HANDLE_IVL)) - async def show_inline_interval_editing(inline_query: InlineQuery): - message_with_keyboard = chat_db["message_with_keyboard"] - edit_interval_message = chat_db["edit_interval_message"] - - if message_with_keyboard is not None and edit_interval_message is not None: - await message_with_keyboard.delete() - await edit_interval_message.delete() - - suggestion = InlineQueryResultArticle( - id=inline_query.query, - title=inline_query.query, - input_message_content=InputTextMessageContent( - message_text=inline_query.query - ) - ) - await inline_query.answer([suggestion], is_personal=True) - - @router.message(F.text.regexp(EDIT_HANDLE_IVL), HasMessageUserUsername(), HasChatState()) - async def handle_interval_editing(message: Message, username: str, chat_state: ChatState): - - parse_pattern = re.compile(EDIT_PARSE_IVL) - parse_match = parse_pattern.match(message.text) - if parse_match: - weekday = parse_match.group("weekday") - st_time_prev = parse_match.group("start_time1") - end_time_prev = parse_match.group("end_time1") - st_time_edit = parse_match.group("start_time2") - end_time_edit = parse_match.group("end_time2") - - interval_prev = f"{st_time_prev} - {end_time_prev}" - interval_edit = f"{st_time_edit} - {end_time_edit}" - - is_valid, status_msg = make_interval_validation_message(interval_str=interval_edit, tz=chat_db["tz"]) + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone + + layout = get_schedule_keyboard(week_schedule=week_schedule, tz=tz) + schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + + user.schedule_msg = schedule_msg.message_id + await save_state(chat_state) + + @router.callback_query(IntervalCallback.filter(F.action == "edit"), HasMessageUserUsername(), HasChatState()) + async def show_interval_editing_instruction( + cb: CallbackQuery, + state: FSMContext, + callback_data: IntervalCallback, + username: str, + chat_state: ChatState + ): + + weekday = callback_data.weekday + interval_str = callback_data.interval + + instruction_text = "Send me the new interval in the hh:mm - hh:mm format." + example_text = "Example: " + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), sep="") + + await state.set_state(IntervalEditingState.EnterNewInterval) + + await cb.answer() + edit_message = await cb.message.answer(text=fmt.text(text)) + + user = await get_user(chat_state, username) + user.to_edit_weekday = weekday + user.to_edit_interval = interval_str + user.to_delete_msg_ids.add(edit_message.message_id) + await save_state(chat_state) + + @router.message( + IntervalEditingState.EnterNewInterval, + HasMessageUserUsername(), + HasChatState() + ) + async def handle_interval_editing(message: Message, state: FSMContext, username: str, chat_state: ChatState): + + parse_pattern = re.compile(INTERVAL_PATTERN) + parse_match = re.findall(parse_pattern, message.text) + + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + tz = user_pm.personal_time_zone + + user.to_delete_msg_ids.add(message.message_id) + + # User entered two "valid" intervals + if len(parse_match) > 1: + msg = await message.answer("Please, enter only one interval.") + user.to_delete_msg_ids.add(msg.message_id) + + # User entered one "valid" interval + if len(parse_match) == 1: + new_interval = parse_match[0] + is_valid, status_msg = make_interval_validation_message(interval_str=new_interval, tz=tz) + + # Interval is valid if is_valid: + old_interval_obj = Interval.from_string(user.to_edit_interval, tz) + new_interval_obj = Interval.from_string(new_interval, tz) + user.schedule[user.to_edit_weekday].remove_interval(old_interval_obj, ignore_inclusion=True) + user.schedule[user.to_edit_weekday].add_interval(new_interval_obj) + + layout = get_schedule_keyboard(user.schedule, tz) + await bot.edit_message_reply_markup( + chat_id=chat_state.chat_id, + message_id=user.schedule_msg, + reply_markup=layout + ) - intervals = schedule_db[weekday]["intervals"] - if interval_prev in intervals: - intervals.remove(interval_prev) - intervals.append(interval_edit) - schedule_db[weekday]["intervals"] = intervals + success_msg = await message.answer("You successfully edited interval!") + await asyncio.sleep(1) + await success_msg.delete() + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) - layout = get_schedule_keyboard(schedule_db) - if chat_db["schedule_msg"]: - old_msg = chat_db["schedule_msg"] - await old_msg.edit_reply_markup(reply_markup=layout) + user.to_delete_msg_ids = set() - await message.delete() + await state.clear() + # Interval is not valid else: - layout = get_interval_error_keyboard(interval_prev, weekday) - keyboard_msg = await message.reply(text=status_msg, reply_markup=layout) + msg = await message.answer(status_msg) + user.to_delete_msg_ids.add(msg.message_id) + + # Text entered by user is not interval + if len(parse_match) == 0: + instruction_text = "Please send me the valid interval in the hh:mm - hh:mm format." + example_text = "Example: " + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), sep="") - chat_db["edit_interval_message"] = message - chat_db["message_with_keyboard"] = keyboard_msg + msg = await message.answer(fmt.text(text)) + user.to_delete_msg_ids.add(msg.message_id) - @router.callback_query(IntervalCallback.filter(F.action == 'add')) - async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback): + await save_state(chat_state) - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - default = personal_default_interval if personal_default_interval is not None else group_default_interval + @router.callback_query(IntervalCallback.filter(F.action == 'add'), HasMessageUserUsername(), HasChatState()) + async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - schedule_db[weekday]["intervals"].append(default) + interval_str = callback_data.interval + + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone + + interval = Interval.from_string(interval_str, tz) + week_schedule[weekday].add_interval(interval) - layout = get_schedule_keyboard(schedule_db) + layout = get_schedule_keyboard(week_schedule, tz) await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) - @router.callback_query(IntervalCallback.filter(F.action == 'remove')) - async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback): - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - default = personal_default_interval if personal_default_interval is not None else group_default_interval + @router.callback_query(IntervalCallback.filter(F.action == 'remove'), HasMessageUserUsername(), HasChatState()) + async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - interval = callback_data.interval.replace("|", ":") - schedule_db[weekday]["intervals"].remove(interval) + interval_str = callback_data.interval - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(default) + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone - layout = get_schedule_keyboard(schedule_db) + interval = Interval.from_string(interval_str, tz) + week_schedule[weekday].remove_interval(interval) + + layout = get_schedule_keyboard(week_schedule, tz) await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) - @router.callback_query(WeekdayCallback.filter(F.action == 'toggle')) - async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback): - personal_default_interval = chat_db["personal_default_working_time"] - group_default_interval = chat_db["default_working_time"] - default = personal_default_interval if personal_default_interval is not None else group_default_interval + @router.callback_query(WeekdayCallback.filter(F.action == 'toggle'), HasMessageUserUsername(), HasChatState()) + async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - schedule_db[weekday]["include"] = not schedule_db[weekday]["include"] - if len(schedule_db[weekday]["intervals"]) == 0 and schedule_db[weekday]["include"]: - schedule_db[weekday]["intervals"].append(default) + user = await get_user(chat_state, username) + user_pm = await load_user_pm(username) + week_schedule = user.schedule + tz = user_pm.personal_time_zone - layout = get_schedule_keyboard(schedule_db) - await cb.message.edit_reply_markup(reply_markup=layout) + week_schedule[weekday].toggle_inclusion() - @router.callback_query(F.data == "cancel_editing_interval") - async def cancel_editing_interval(cb: CallbackQuery): - message_with_keyboard = cb.message - edit_interval_message = cb.message.reply_to_message + layout = get_schedule_keyboard(week_schedule, tz) + await cb.message.edit_reply_markup(reply_markup=layout) + await save_state(chat_state) - await message_with_keyboard.delete() - await edit_interval_message.delete() + @router.callback_query(F.data == "#") + async def handle_placeholders(cb: CallbackQuery): + await cb.answer() From dfb05d8cbdbaf91eba580083f797b28a48f53641 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 14:29:18 +0300 Subject: [PATCH 82/97] refactor(linter): delete unused imports and other errors caught by linter --- bot/bot.py | 7 ------- bot/commands.py | 2 -- bot/constants.py | 2 -- bot/custom_types.py | 2 -- bot/handlers.py | 4 ++-- bot/language.py | 3 --- bot/messages.py | 4 ---- bot/reminder.py | 2 +- bot/work_time.py | 1 - 9 files changed, 3 insertions(+), 24 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index e5ad775..997f8c8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -10,7 +10,6 @@ from pytz import utc from . import db, handlers -from .commands import BotCommands from .constants import jobstore from .custom_types import ChatId, SendMessage from .meeting import schedule_meeting @@ -41,12 +40,6 @@ async def restore_scheduled_jobs( ) -async def on_startup(): - bot_commands = [ - BotCommands() - ] - - async def main(settings: Settings) -> None: await db.main(settings=settings) diff --git a/bot/commands.py b/bot/commands.py index 654a925..3715ef4 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -1,8 +1,6 @@ from aiogram.utils.i18n import gettext as _ from pydantic import BaseModel -from .language import Language - class BotCommands(BaseModel): # global commands diff --git a/bot/constants.py b/bot/constants.py index 9fb7b86..742c160 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -1,5 +1,3 @@ -from datetime import datetime - from .intervals import DaySchedule ENCODING = "utf-8" diff --git a/bot/custom_types.py b/bot/custom_types.py index 753fee9..5ccc82d 100644 --- a/bot/custom_types.py +++ b/bot/custom_types.py @@ -1,9 +1,7 @@ from typing import Protocol, Optional -from aiogram import Bot from aiogram.types import Message -from . import state from .chat import ChatId from .state import ChatState diff --git a/bot/handlers.py b/bot/handlers.py index 85b9a23..25ee242 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -131,7 +131,7 @@ async def set_meetings_time( start_date=html.bold(meeting_time.strftime("%Y-%m-%d")), ) ) - except Exception as e: + except Exception: await message.reply( dedent( _( @@ -251,7 +251,7 @@ async def set_personal_meetings_days( meeting_days=html.bold(", ".join(day_tokens)) ) ) - except Exception as e: + except Exception: await message.reply( dedent( _( diff --git a/bot/language.py b/bot/language.py index 09498eb..81ac71e 100644 --- a/bot/language.py +++ b/bot/language.py @@ -1,8 +1,5 @@ -from dataclasses import dataclass from enum import Enum -from pydantic import BaseModel - class Language(Enum): ru = "ru" diff --git a/bot/messages.py b/bot/messages.py index 3be1a6d..4d0e27c 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -1,5 +1,3 @@ -from dataclasses import dataclass -from datetime import datetime from textwrap import dedent from typing import List, Tuple @@ -7,8 +5,6 @@ from aiogram.utils.i18n import gettext as _ from .commands import bot_command_descriptions, bot_command_names -from .constants import day_of_week_pretty -from .language import Language from .intervals import Interval, InvalidTimeFormatException, InvalidIntervalException diff --git a/bot/reminder.py b/bot/reminder.py index 6c90f07..e4638b3 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -9,7 +9,7 @@ from apscheduler.triggers.interval import IntervalTrigger from .messages import make_daily_messages -from .constants import day_of_week, jobstore +from .constants import jobstore from .custom_types import ChatId, SendMessage from .state import load_state, load_user_pm, get_user, ChatState from textwrap import dedent diff --git a/bot/work_time.py b/bot/work_time.py index d1a0d74..c95260e 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -6,7 +6,6 @@ from aiogram.types import Message, CallbackQuery from aiogram.fsm.context import FSMContext from aiogram.utils import markdown as fmt -from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler from .commands import bot_command_names From 65931afc4799bcc38a9c06d1de78d3aaf0d0b12c Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 7 Jul 2024 16:55:11 +0300 Subject: [PATCH 83/97] feat(schedule): add timezone changing support --- bot/intervals.py | 44 +++++++++++++++++++++++++------------------- bot/keyboards.py | 14 +++++++------- bot/state.py | 2 +- bot/work_time.py | 19 ++++++++----------- 4 files changed, 41 insertions(+), 38 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 007fb27..cf43bf3 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,9 +1,9 @@ from typing import List, Tuple from collections import Counter -from datetime import datetime, time +from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo -from pydantic import BaseModel, field_validator +from pydantic import BaseModel from pytz import timezone, UnknownTimeZoneError @@ -37,16 +37,17 @@ class Interval(BaseModel): end_time_utc: datetime tz: str = "UTC" - @field_validator("tz") - def zone_must_be_valid(cls, tz: str): + @staticmethod + def validate_zone(tz: str): try: timezone(tz) except UnknownTimeZoneError: raise ValueError("You should pass valid zone name") - return tz @classmethod - def from_string(cls, interval_str: str, tz: str = "UTC"): + def from_string(cls, interval_str: str, tz: str = "UTC", shift: int = 0): + cls.validate_zone(tz) + start_str, end_str = interval_str.replace(" ", "").split('-') start_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(start_str), ZoneInfo(tz)) end_time = datetime.combine(IMMUTABLE_DATE, cls.parse_time(end_str), ZoneInfo(tz)) @@ -54,8 +55,10 @@ def from_string(cls, interval_str: str, tz: str = "UTC"): if start_time >= end_time: raise InvalidIntervalException(start_time.time(), end_time.time()) - start_time_utc = cls.convert_to_utc(start_time) - end_time_utc = cls.convert_to_utc(end_time) + hour_delta = timedelta(hours=shift) + + start_time_utc = cls.convert_to_utc(start_time) - hour_delta + end_time_utc = cls.convert_to_utc(end_time) - hour_delta return cls(start_time_utc=start_time_utc, end_time_utc=end_time_utc, tz=tz) @@ -76,17 +79,19 @@ def parse_time(time_str: str) -> time: except ValueError: raise InvalidTimeFormatException(time_str) - def convert_to_timezone(self, new_tz: str) -> Tuple[datetime, datetime]: - new_start_dt = self.start_time_utc.astimezone(ZoneInfo(new_tz)) - new_end_dt = self.end_time_utc.astimezone(ZoneInfo(new_tz)) + def convert_to_timezone(self, new_tz: str, shift: int) -> Tuple[datetime, datetime]: + self.validate_zone(new_tz) + + hour_delta = timedelta(hours=shift) + + new_start_dt = self.start_time_utc.astimezone(ZoneInfo(new_tz)) + hour_delta + new_end_dt = self.end_time_utc.astimezone(ZoneInfo(new_tz)) + hour_delta return new_start_dt, new_end_dt - def to_string(self, tz: str = "UTC"): - if tz == "UTC": - start_time = self.start_time_utc - end_time = self.end_time_utc - else: - start_time, end_time = self.convert_to_timezone(new_tz=tz) + def to_string(self, tz: str = "UTC", shift: int = 0): + self.validate_zone(tz) + + start_time, end_time = self.convert_to_timezone(new_tz=tz, shift=shift) return f"{start_time.strftime('%H:%M')} - {end_time.strftime('%H:%M')}" @@ -169,9 +174,10 @@ def toggle_inclusion(self) -> None: if self.included and len(self.intervals) == 0: self.add_interval(DEFAULT_INTERVAL) - def add_interval(self, interval: Interval) -> None: + def add_interval(self, interval: Interval, merge=False) -> None: self.intervals.append(interval) - self.intervals = Interval.merge_intervals(self.intervals) + if merge: + self.intervals = Interval.merge_intervals(self.intervals) def remove_interval(self, interval: Interval, ignore_inclusion=False) -> None: self.intervals.remove(interval) diff --git a/bot/keyboards.py b/bot/keyboards.py index 44e925a..a5af442 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -16,10 +16,10 @@ REMOVE = "โœ–๏ธ" -def get_interval_keyboard(interval: Interval, weekday: str, tz: str) -> InlineKeyboardBuilder: +def get_interval_keyboard(interval: Interval, weekday: str, tz: str, shift: int) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() - interval_str = interval.to_string(tz=tz) - default_interval_str = DEFAULT_INTERVAL.to_string(tz=tz) + interval_str = interval.to_string(tz=tz, shift=shift) + default_interval_str = DEFAULT_INTERVAL.to_string(tz=tz, shift=shift) builder.button( text=interval_str, @@ -38,7 +38,7 @@ def get_interval_keyboard(interval: Interval, weekday: str, tz: str) -> InlineKe return builder -def get_weekday_keyboard(weekday: DaySchedule, tz: str) -> InlineKeyboardBuilder: +def get_weekday_keyboard(weekday: DaySchedule, tz: str, shift: int) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() included_text = INCLUDED_3 if weekday.included else NOT_INCLUDED_3 @@ -52,17 +52,17 @@ def get_weekday_keyboard(weekday: DaySchedule, tz: str) -> InlineKeyboardBuilder if weekday.included: for interval in weekday.intervals: - interval_builder = get_interval_keyboard(interval, weekday.name, tz) + interval_builder = get_interval_keyboard(interval, weekday.name, tz, shift) builder.attach(interval_builder) return builder -def get_schedule_keyboard(week_schedule: Dict[str, DaySchedule], tz: str) -> InlineKeyboardMarkup: +def get_schedule_keyboard(week_schedule: Dict[str, DaySchedule], tz: str, shift: int) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() for weekday in days_array: - weekday_builder = get_weekday_keyboard(week_schedule[weekday], tz) + weekday_builder = get_weekday_keyboard(week_schedule[weekday], tz, shift) builder.attach(weekday_builder) return builder.as_markup() diff --git a/bot/state.py b/bot/state.py index d1dd441..6c8daac 100644 --- a/bot/state.py +++ b/bot/state.py @@ -16,7 +16,7 @@ class ChatUser(BaseModel): is_joined: bool = False schedule: Dict[str, DaySchedule] = default_user_schedule personal_default_working_time: Optional[Interval] = None - time_zone_shift: int = 0 + time_zone_shift: int = 0 # 0 for dynamic schedule; {UTC_offset_old - UTC_offset_new} for static schedule; meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday reminder_period: Optional[int] = None non_replied_daily_msgs: set[int] = set(range(0, 3)) diff --git a/bot/work_time.py b/bot/work_time.py index c95260e..508c205 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -21,9 +21,6 @@ INTERVAL_PATTERN = r"\b\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\b" -# TODO: Apply tz validation in Interval class -# TODO: Handle two scenarios for timezone changing via shift and write logic for intervals for those cases -# TODO: Check code and messages by scenario: https://github.com/team-work-tools/team-work-telegram-bot/pull/106 # TODO: Write logic for setting group schedule: https://t.me/c/2215513034/6/2076, https://t.me/c/2215513034/6/2083 @@ -38,7 +35,7 @@ async def show_user_schedule(message: Message, username: str, chat_state: ChatSt week_schedule = user.schedule tz = user_pm.personal_time_zone - layout = get_schedule_keyboard(week_schedule=week_schedule, tz=tz) + layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) user.schedule_msg = schedule_msg.message_id @@ -99,12 +96,12 @@ async def handle_interval_editing(message: Message, state: FSMContext, username: # Interval is valid if is_valid: - old_interval_obj = Interval.from_string(user.to_edit_interval, tz) - new_interval_obj = Interval.from_string(new_interval, tz) + old_interval_obj = Interval.from_string(user.to_edit_interval, tz, user.time_zone_shift) + new_interval_obj = Interval.from_string(new_interval, tz, user.time_zone_shift) user.schedule[user.to_edit_weekday].remove_interval(old_interval_obj, ignore_inclusion=True) user.schedule[user.to_edit_weekday].add_interval(new_interval_obj) - layout = get_schedule_keyboard(user.schedule, tz) + layout = get_schedule_keyboard(user.schedule, tz, user.time_zone_shift) await bot.edit_message_reply_markup( chat_id=chat_state.chat_id, message_id=user.schedule_msg, @@ -151,7 +148,7 @@ async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, usern interval = Interval.from_string(interval_str, tz) week_schedule[weekday].add_interval(interval) - layout = get_schedule_keyboard(week_schedule, tz) + layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) await cb.message.edit_reply_markup(reply_markup=layout) await save_state(chat_state) @@ -166,10 +163,10 @@ async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback, us week_schedule = user.schedule tz = user_pm.personal_time_zone - interval = Interval.from_string(interval_str, tz) + interval = Interval.from_string(interval_str, tz, user.time_zone_shift) week_schedule[weekday].remove_interval(interval) - layout = get_schedule_keyboard(week_schedule, tz) + layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) await cb.message.edit_reply_markup(reply_markup=layout) await save_state(chat_state) @@ -185,7 +182,7 @@ async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback, user week_schedule[weekday].toggle_inclusion() - layout = get_schedule_keyboard(week_schedule, tz) + layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) await cb.message.edit_reply_markup(reply_markup=layout) await save_state(chat_state) From 28c9597dc19dcdc1f92d2a0304c3a4c56d34d0b7 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Tue, 9 Jul 2024 00:53:17 +0300 Subject: [PATCH 84/97] fix(reminders): fix indents in user block message and add text to notification message --- bot/reminder.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/bot/reminder.py b/bot/reminder.py index e4638b3..31bd0ff 100644 --- a/bot/reminder.py +++ b/bot/reminder.py @@ -12,7 +12,6 @@ from .constants import jobstore from .custom_types import ChatId, SendMessage from .state import load_state, load_user_pm, get_user, ChatState -from textwrap import dedent async def send_reminder_messages( @@ -45,7 +44,7 @@ async def send_reminder_messages( chat_type=chat_type ) if msg_link: # supergroup - reminder_message += "\n" + msg_link + reminder_message += "\n" + messages[reply] + msg_link else: # group chanel or private reminder_message = messages[reply] @@ -73,14 +72,10 @@ async def send_reminder_messages( bot_username = bot_info.username if chat_type != "private": - banned_msg = dedent( - _( - """ - {username}, please unblock {bot_username} (it's me) in our private chat - so that I can send you reminders about missed daily meeting questions. - """ - ).format(username=username, bot_username=bot_username) - ) + banned_msg = _( + "@{username}, please unblock @{bot_username} (it's me) in our private chat " + "so that I can send you reminders about missed daily meeting questions." + ).format(username=username, bot_username=bot_username) await send_message( chat_id=meeting_chat_id, From 454d2177d2e4f53ec36a22c5a4ca5faa6c40dd2e Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 00:08:20 +0300 Subject: [PATCH 85/97] feat(commands): edit command name for schedule editing --- bot/commands.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/bot/commands.py b/bot/commands.py index 3715ef4..ebb34e7 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -13,13 +13,11 @@ class BotCommands(BaseModel): set_meetings_time_zone: str set_meetings_time: str set_meetings_days: str - set_default_working_time: str # personal settings join: str skip: str set_personal_meetings_days: str - set_personal_working_time: str - set_personal_default_working_time: str + set_working_hours: str set_reminder_period: str join_today: str skip_today: str @@ -44,13 +42,11 @@ class BotCommandNames(BotCommands): set_meetings_time_zone="set_meetings_time_zone", set_meetings_time="set_meetings_time", set_meetings_days="set_meetings_days", - set_default_working_time="set_default_working_time", # personal settings join="join", skip="skip", set_personal_meetings_days="set_personal_meetings_days", - set_personal_working_time="set_personal_working_time", - set_personal_default_working_time="set_personal_default_working_time", + set_working_hours="set_working_hours", set_reminder_period="set_reminder_period", join_today="join_today", skip_today="skip_today", @@ -78,13 +74,11 @@ def bot_command_descriptions() -> BotCommandDescriptions: set_meetings_time_zone=_("Set meetings time zone."), set_meetings_time=_("Set meetings time."), set_meetings_days=_("Set meetings days."), - set_default_working_time=_("Set default working interval for weekday."), # personal settings join=_("Join meetings."), skip=_("Skip meetings."), set_personal_meetings_days=_("Set the days when you can join meetings."), - set_personal_working_time=_("Set your working schedule."), - set_personal_default_working_time=_("Set personal default working interval for weekday."), + set_working_hours=_("Set your working schedule."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), skip_today=_("Skip only today's meeting."), From d0a55739716b847ec6b9b35191399a1bd7747cfa Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 00:10:43 +0300 Subject: [PATCH 86/97] refactor(schedule): edit command and function names, refactor field order in db --- bot/constants.py | 2 +- bot/handlers.py | 4 ++-- bot/state.py | 30 +++++++++++++++++++----------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 742c160..1d78b4c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -37,4 +37,4 @@ class AppCommands: empty_schedule = {day: DaySchedule(name=day) for day in days_array} -default_user_schedule = empty_schedule +default_schedule = empty_schedule diff --git a/bot/handlers.py b/bot/handlers.py index 25ee242..d22f00a 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -15,7 +15,7 @@ from .filters import HasChatState, HasMessageText, HasMessageUserUsername, IsReplyToMeetingMessage from .meeting import schedule_meeting from .reminder import update_reminders -from .work_time import handle_working_time +from .work_time import handle_working_hours from .messages import make_help_message from .state import ChatState, save_state, get_user, load_user_pm, create_user_pm, save_user_pm @@ -35,7 +35,7 @@ def make_router(scheduler: AsyncIOScheduler, send_message: SendMessage, bot: Bot scheduler=scheduler, send_message=send_message, router=router, bot=bot ) - handle_working_time( + handle_working_hours( scheduler=scheduler, send_message=send_message, router=router, bot=bot ) diff --git a/bot/state.py b/bot/state.py index 6c8daac..89583c5 100644 --- a/bot/state.py +++ b/bot/state.py @@ -7,21 +7,26 @@ from .chat import ChatId from .language import Language -from .intervals import DaySchedule, Interval -from .constants import default_time_zone, default_user_schedule +from .intervals import DaySchedule +from .constants import default_time_zone, default_schedule class ChatUser(BaseModel): - username: str = "" + username: str = "" is_joined: bool = False - schedule: Dict[str, DaySchedule] = default_user_schedule - personal_default_working_time: Optional[Interval] = None - time_zone_shift: int = 0 # 0 for dynamic schedule; {UTC_offset_old - UTC_offset_new} for static schedule; + time_zone: str = default_time_zone + meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday - reminder_period: Optional[int] = None non_replied_daily_msgs: set[int] = set(range(0, 3)) + reminder_period: Optional[int] = None + + schedule: Dict[str, DaySchedule] = default_schedule + temp_schedule: Optional[Dict[str, DaySchedule]] = None + time_zone_shift: int = 0 # 0 for dynamic schedule; {UTC_offset_old - UTC_offset_new} for static schedule; + # TODO: relocate these fields to cache (Redis for example) + schedule_mode: Optional[str] = None schedule_msg: Optional[int] = None to_delete_msg_ids: set[int] = set() to_edit_weekday: Optional[str] = None @@ -53,13 +58,17 @@ async def create_user(username: str) -> ChatUser: class ChatState(Document): language: Language = Language.default time_zone: str = default_time_zone - default_working_time: Optional[Interval] = None - meeting_time: Optional[datetime] = None - meeting_msg_ids: list[int] = [] topic_id: Optional[int] = None chat_id: Annotated[ChatId, Indexed(index_type=pymongo.ASCENDING)] users: Dict[str, ChatUser] = dict() + meeting_time: Optional[datetime] = None + meeting_msg_ids: list[int] = [] + + schedule: Dict[str, DaySchedule] = default_schedule + temp_schedule: Optional[Dict[str, DaySchedule]] = None + time_zone_shift: int = 0 # 0 for dynamic schedule; {UTC_offset_old - UTC_offset_new} for static schedule; + async def get_user(chat_state: ChatState, username: str) -> ChatUser: """Load a user from the ChatState by username or create a new one if not found. @@ -138,7 +147,6 @@ async def save_state(chat_state: ChatState) -> None: class UserPM(Document): username: str chat_id: Annotated[ChatId, Indexed(index_type=pymongo.ASCENDING)] - personal_time_zone: str = default_time_zone async def create_user_pm(username: str, chat_id: ChatId) -> UserPM: From 1a105a9ddd1f78846effb0a6c23c3b7fe6746176 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 00:12:13 +0300 Subject: [PATCH 87/97] fix(intervals): rework algorithm for interval merging --- bot/intervals.py | 76 ++++++++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 34 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index cf43bf3..4a5d22d 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,4 +1,4 @@ -from typing import List, Tuple +from typing import List, Tuple, Dict from collections import Counter from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo @@ -14,7 +14,7 @@ class IntervalException(Exception): class InvalidTimeFormatException(IntervalException): """Raised when the time format is invalid""" - def __init__(self, time_str: str, message="Time format must be either HH:MM or HH.MM"): + def __init__(self, time_str: str, message="Time must be in HH:MM format."): self.time_str = time_str self.message = message super().__init__(self.message) @@ -22,7 +22,7 @@ def __init__(self, time_str: str, message="Time format must be either HH:MM or H class InvalidIntervalException(IntervalException): """Raised when the interval is invalid""" - def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time"): + def __init__(self, start_time: time, end_time: time, message: str = "Start time must be earlier than end time."): self.start_time: str = start_time.strftime("%H:%M") self.end_time: str = end_time.strftime("%H:%M") self.message: str = message @@ -72,8 +72,6 @@ def parse_time(time_str: str) -> time: try: if ':' in time_str: return datetime.strptime(time_str, "%H:%M").time() - elif '.' in time_str: - return datetime.strptime(time_str, "%H.%M").time() else: raise InvalidTimeFormatException(time_str) except ValueError: @@ -131,34 +129,22 @@ def merge_intervals(intervals): # Sort intervals by start time sorted_intervals = sorted(intervals, key=lambda x: x.start_time_utc.time()) - # Exclude repeating intervals - counter = Counter(sorted_intervals) - unique = [] - n_unique = [] - for interval in sorted_intervals: - if counter[interval] == 1: - unique.append(interval) + # Merge intervals + merged_intervals = list() + merged_intervals.append(sorted_intervals[0]) + for current in sorted_intervals[1:]: + last = merged_intervals[-1] + + if current.overlaps_with(last) or current.start_time_utc.time() <= last.end_time_utc.time(): + merged_intervals[-1] = Interval( + start_time_utc=min(last.start_time_utc, current.start_time_utc), + end_time_utc=max(last.end_time_utc, current.end_time_utc), + tz=last.tz + ) else: - n_unique.append(interval) + merged_intervals.append(current) - # Merge unique intervals - merged_intervals = [] - if len(unique) > 0: - merged_intervals.append(unique[0]) - for current in unique[1:]: - last = merged_intervals[-1] - - if current.overlaps_with(last) or current.start_time_utc.time() <= last.end_time_utc.time(): - # Merge intervals - merged_intervals[-1] = Interval( - start_time_utc=min(last.start_time_utc, current.start_time_utc), - end_time_utc=max(last.end_time_utc, current.end_time_utc), - tz=last.tz - ) - else: - merged_intervals.append(current) - - return list(sorted(merged_intervals + n_unique, key=lambda x: x.start_time_utc.time())) + return merged_intervals DEFAULT_INTERVAL = Interval.from_string("9:00 - 17:00", "Europe/Moscow") @@ -174,10 +160,8 @@ def toggle_inclusion(self) -> None: if self.included and len(self.intervals) == 0: self.add_interval(DEFAULT_INTERVAL) - def add_interval(self, interval: Interval, merge=False) -> None: + def add_interval(self, interval: Interval) -> None: self.intervals.append(interval) - if merge: - self.intervals = Interval.merge_intervals(self.intervals) def remove_interval(self, interval: Interval, ignore_inclusion=False) -> None: self.intervals.remove(interval) @@ -185,9 +169,33 @@ def remove_interval(self, interval: Interval, ignore_inclusion=False) -> None: if len(self.intervals) == 0 and self.included: self.toggle_inclusion() + def normalize_intervals(self): + # Delete duplicates + unique_intervals = set(self.intervals) + # Sort and merge intervals + if len(self.intervals) > 1: + self.intervals = Interval.merge_intervals(unique_intervals) + else: + self.intervals = list(unique_intervals) + return self + + def is_empty(self): + return not self.included and len(self.intervals) == 0 + @staticmethod def is_workday(day: str) -> bool: return day not in {"Saturday", "Sunday"} def __hash__(self): return hash(self.name) + + def __eq__(self, other): + if not isinstance(other, DaySchedule): + return False + return (self.name == other.name and + self.included == other.included and + self.intervals == other.intervals) + + +def schedule_is_empty(schedule: Dict[str, DaySchedule]) -> bool: + return all(weekday.is_empty() for weekday in schedule.values()) From b9a619f162c193ba56bad8bfbff81f9427356e2d Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 00:13:42 +0300 Subject: [PATCH 88/97] feat(keyboards): add missing keyboards from scenario --- bot/keyboards.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bot/keyboards.py b/bot/keyboards.py index a5af442..8f5404a 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -65,4 +65,28 @@ def get_schedule_keyboard(week_schedule: Dict[str, DaySchedule], tz: str, shift: weekday_builder = get_weekday_keyboard(week_schedule[weekday], tz, shift) builder.attach(weekday_builder) + cancel = InlineKeyboardButton(text="Cancel", callback_data="cancel_schedule") + save = InlineKeyboardButton(text="Save", callback_data="save_schedule") + builder.row(cancel, save) + return builder.as_markup() + + +def get_schedule_options() -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button(text="Default", callback_data="default_schedule") + builder.button(text="Personal", callback_data="personal_schedule") + + return builder.adjust(2).as_markup() + + +def get_interval_edit_options(weekday: str, interval_str: str) -> InlineKeyboardMarkup: + builder = InlineKeyboardBuilder() + + builder.button( + text="Enter again", + callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='edit')) + builder.button(text="Cancel", callback_data="cancel_interval_edit") + + return builder.adjust(2).as_markup() From 9f4fb632ce0acec2b6699ee35957b4908796f800 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 00:14:30 +0300 Subject: [PATCH 89/97] feat(messages): add missing messages from scenario --- bot/messages.py | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/bot/messages.py b/bot/messages.py index 4d0e27c..59cb36d 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -2,6 +2,7 @@ from typing import List, Tuple from aiogram import html +from aiogram.utils import markdown as fmt from aiogram.utils.i18n import gettext as _ from .commands import bot_command_descriptions, bot_command_names @@ -36,6 +37,7 @@ def make_help_message() -> str: {html.bold(_("Personal settings commands"))} /{command_names.set_personal_meetings_days} - {command_descriptions.set_personal_meetings_days} /{command_names.set_reminder_period} - {command_descriptions.set_reminder_period} + /{command_names.set_working_hours} - {command_descriptions.set_working_hours} /{command_names.join} - {command_descriptions.join} /{command_names.skip} - {command_descriptions.skip} @@ -56,22 +58,37 @@ def make_daily_messages(usernames: str) -> List[str]: def make_interval_validation_message(interval_str: str, tz: str) -> Tuple[bool, str]: - new = "Please, reenter interval." try: interval = Interval.from_string(interval_str=interval_str, tz=tz) - msg = _("Successfully parsed interval: {interval}\n{new}").format(interval=interval, new=new) + msg = _("Successfully parsed interval: {interval}").format(interval=interval) return True, msg except InvalidTimeFormatException as e: - msg = _("Invalid time format for '{time}'. {msg}\n{new}").format(time=e.time_str, msg=e.message, new=new) + msg = _("Invalid time format for '{time}'. {msg}\n").format(time=e.time_str, msg=e.message) return False, msg except InvalidIntervalException as e: - msg = _("{msg} (start: {start}, end: {end})\n{new}").format( + msg = _("{msg} (start: {start}, end: {end}).\n").format( msg=e.message, start=e.start_time, - end=e.end_time, - new=new + end=e.end_time ) return False, msg except Exception as e: msg = _("An unexpected error occurred. {error}").format(error=str(e)) return False, msg + + +def make_interval_editing_instruction() -> str: + instruction_text = "Send me the new interval in the hh:mm - hh:mm format." + example_text = "Example: " + note = "Notes:\nThe example provides an interval copyable by clicking or tapping it." + + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), "\n", note, sep="") + return text + + +def make_interval_editing_error(error_msg: str) -> str: + again_text = "Press 'Enter again' to enter the interval again." + cancel_text = "Press 'Cancel' to cancel editing this interval." + + text = fmt.text(error_msg, "\n", again_text, "\n", cancel_text, sep="") + return text From dd1be6e949da6f72444ed9ff25a7870496ca2d96 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 00:16:00 +0300 Subject: [PATCH 90/97] feat(schedule): add logic to work with default and personal schedule + keyboards for interval editing + Cancel/Save layout --- bot/work_time.py | 238 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 179 insertions(+), 59 deletions(-) diff --git a/bot/work_time.py b/bot/work_time.py index 508c205..6c278d6 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -10,35 +10,62 @@ from .commands import bot_command_names from .custom_types import SendMessage -from .messages import make_interval_validation_message +from .messages import make_interval_validation_message, make_interval_editing_instruction, make_interval_editing_error from .callbacks import IntervalCallback, WeekdayCallback from .fsm_states import IntervalEditingState -from .intervals import Interval +from .intervals import Interval, schedule_is_empty from .filters import HasChatState, HasMessageUserUsername -from .keyboards import get_schedule_keyboard -from .state import ChatState, save_state, get_user, load_user_pm +from .keyboards import get_schedule_keyboard, get_schedule_options, get_interval_edit_options +from .state import ChatState, save_state, get_user -INTERVAL_PATTERN = r"\b\d{1,2}[:.]\d{2}\s*-\s*\d{1,2}[:.]\d{2}\b" +INTERVAL_PATTERN = r"^\d{1,2}:\d{2}\s*-\s*\d{1,2}:\d{2}$" -# TODO: Write logic for setting group schedule: https://t.me/c/2215513034/6/2076, https://t.me/c/2215513034/6/2083 +# TODO: Consider the case: After changing timezone working interval changed from 22:00 - 00:00 to 23:00 - 01:00 (fix it) +# TODO: Think how to extract duplicating code -def handle_working_time( +def handle_working_hours( scheduler: AsyncIOScheduler, send_message: SendMessage, router: Router, bot: Bot ): - @router.message(Command(bot_command_names.set_personal_working_time), HasMessageUserUsername(), HasChatState()) - async def show_user_schedule(message: Message, username: str, chat_state: ChatState): + @router.message(Command(bot_command_names.set_working_hours)) + async def show_schedule_options(message: Message): - user = await get_user(chat_state, username) - user_pm = await load_user_pm(username) - week_schedule = user.schedule - tz = user_pm.personal_time_zone + layout = get_schedule_options() + + st = "Press 'Default' to set working hours that will be used as default personal working hours by all people." + end = "Press 'Personal' to set personal working hours." + text = fmt.text(st, "\n", end, sep="") + + await message.answer(text=text, reply_markup=layout) - layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) - schedule_msg = await message.answer(f"@{username}, here is your schedule", reply_markup=layout) + @router.callback_query(F.data.regexp("(default|personal)_schedule"), HasMessageUserUsername(), HasChatState()) + async def show_schedule(cb: CallbackQuery, username: str, chat_state: ChatState): + + await cb.answer() + mode = cb.data.split("_")[0] + + user = await get_user(chat_state, username) + + if mode == "personal": + user.temp_schedule = user.schedule + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + chat_state.temp_schedule = chat_state.schedule + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift + + layout = get_schedule_keyboard(week_schedule, tz, shift) + schedule_msg = await cb.message.answer(f"@{username}, here is your {mode} schedule", reply_markup=layout) + + # Cache + user.schedule_mode = mode user.schedule_msg = schedule_msg.message_id + await save_state(chat_state) @router.callback_query(IntervalCallback.filter(F.action == "edit"), HasMessageUserUsername(), HasChatState()) @@ -53,15 +80,14 @@ async def show_interval_editing_instruction( weekday = callback_data.weekday interval_str = callback_data.interval - instruction_text = "Send me the new interval in the hh:mm - hh:mm format." - example_text = "Example: " - text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), sep="") + text = make_interval_editing_instruction() await state.set_state(IntervalEditingState.EnterNewInterval) await cb.answer() - edit_message = await cb.message.answer(text=fmt.text(text)) + edit_message = await cb.message.answer(text=text) + # Cache user = await get_user(chat_state, username) user.to_edit_weekday = weekday user.to_edit_interval = interval_str @@ -76,39 +102,49 @@ async def show_interval_editing_instruction( async def handle_interval_editing(message: Message, state: FSMContext, username: str, chat_state: ChatState): parse_pattern = re.compile(INTERVAL_PATTERN) - parse_match = re.findall(parse_pattern, message.text) + parse_match = re.fullmatch(parse_pattern, message.text.strip()) + # Cache user = await get_user(chat_state, username) - user_pm = await load_user_pm(username) - tz = user_pm.personal_time_zone - user.to_delete_msg_ids.add(message.message_id) - # User entered two "valid" intervals - if len(parse_match) > 1: - msg = await message.answer("Please, enter only one interval.") - user.to_delete_msg_ids.add(msg.message_id) + # Text entered by user is not interval + error_msg_text = "" + if not parse_match: + error_msg_text = f"The interval {message.text.strip()} isn't in the hh:mm - hh:mm format.\n" # User entered one "valid" interval - if len(parse_match) == 1: + else: + mode = user.schedule_mode + if mode == "personal": + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift + + weekday = user.to_edit_weekday + old_interval = user.to_edit_interval new_interval = parse_match[0] - is_valid, status_msg = make_interval_validation_message(interval_str=new_interval, tz=tz) + is_valid, error_msg_text = make_interval_validation_message(interval_str=new_interval, tz=tz) # Interval is valid if is_valid: - old_interval_obj = Interval.from_string(user.to_edit_interval, tz, user.time_zone_shift) - new_interval_obj = Interval.from_string(new_interval, tz, user.time_zone_shift) - user.schedule[user.to_edit_weekday].remove_interval(old_interval_obj, ignore_inclusion=True) - user.schedule[user.to_edit_weekday].add_interval(new_interval_obj) + old_interval_obj = Interval.from_string(old_interval, tz, shift) + new_interval_obj = Interval.from_string(new_interval, tz, shift) + week_schedule[weekday].remove_interval(old_interval_obj, ignore_inclusion=True) + week_schedule[weekday].add_interval(new_interval_obj) - layout = get_schedule_keyboard(user.schedule, tz, user.time_zone_shift) + layout = get_schedule_keyboard(week_schedule, tz, shift) await bot.edit_message_reply_markup( chat_id=chat_state.chat_id, message_id=user.schedule_msg, reply_markup=layout ) - success_msg = await message.answer("You successfully edited interval!") + success_msg = await message.answer(f"OK, the interval was set to {new_interval}.") await asyncio.sleep(1) await success_msg.delete() for msg_id in user.to_delete_msg_ids: @@ -117,21 +153,30 @@ async def handle_interval_editing(message: Message, state: FSMContext, username: user.to_delete_msg_ids = set() await state.clear() + await save_state(chat_state) + return - # Interval is not valid - else: - msg = await message.answer(status_msg) - user.to_delete_msg_ids.add(msg.message_id) + error_msg_text = make_interval_editing_error(error_msg=error_msg_text) + layout = get_interval_edit_options(weekday=user.to_edit_weekday, interval_str=user.to_edit_interval) + error_msg = await message.answer(text=error_msg_text, reply_markup=layout) - # Text entered by user is not interval - if len(parse_match) == 0: - instruction_text = "Please send me the valid interval in the hh:mm - hh:mm format." - example_text = "Example: " - text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), sep="") + user.to_delete_msg_ids.add(error_msg.message_id) + + await save_state(chat_state) - msg = await message.answer(fmt.text(text)) - user.to_delete_msg_ids.add(msg.message_id) + @router.callback_query(F.data == "cancel_interval_edit", HasMessageUserUsername(), HasChatState()) + async def cancel_interval_editing(cb: CallbackQuery, state: FSMContext, username: str, chat_state: ChatState): + await cb.answer() + user = await get_user(chat_state, username) + + user.to_edit_weekday = None + user.to_edit_interval = None + + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) + user.to_delete_msg_ids = set() + await state.clear() await save_state(chat_state) @router.callback_query(IntervalCallback.filter(F.action == 'add'), HasMessageUserUsername(), HasChatState()) @@ -141,14 +186,21 @@ async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, usern interval_str = callback_data.interval user = await get_user(chat_state, username) - user_pm = await load_user_pm(username) - week_schedule = user.schedule - tz = user_pm.personal_time_zone + + mode = user.schedule_mode + if mode == "personal": + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift interval = Interval.from_string(interval_str, tz) week_schedule[weekday].add_interval(interval) - layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) + layout = get_schedule_keyboard(week_schedule, tz, shift) await cb.message.edit_reply_markup(reply_markup=layout) await save_state(chat_state) @@ -159,14 +211,21 @@ async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback, us interval_str = callback_data.interval user = await get_user(chat_state, username) - user_pm = await load_user_pm(username) - week_schedule = user.schedule - tz = user_pm.personal_time_zone - interval = Interval.from_string(interval_str, tz, user.time_zone_shift) + mode = user.schedule_mode + if mode == "personal": + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift + + interval = Interval.from_string(interval_str, tz, shift) week_schedule[weekday].remove_interval(interval) - layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) + layout = get_schedule_keyboard(week_schedule, tz, shift) await cb.message.edit_reply_markup(reply_markup=layout) await save_state(chat_state) @@ -176,16 +235,77 @@ async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback, user weekday = callback_data.weekday user = await get_user(chat_state, username) - user_pm = await load_user_pm(username) - week_schedule = user.schedule - tz = user_pm.personal_time_zone + + mode = user.schedule_mode + if mode == "personal": + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift week_schedule[weekday].toggle_inclusion() - layout = get_schedule_keyboard(week_schedule, tz, user.time_zone_shift) + layout = get_schedule_keyboard(week_schedule, tz, shift) await cb.message.edit_reply_markup(reply_markup=layout) await save_state(chat_state) + @router.callback_query(F.data == "cancel_schedule", HasMessageUserUsername(), HasChatState()) + async def cancel_changes(cb: CallbackQuery, state: FSMContext, username: str, chat_state: ChatState): + + await cb.answer() + + user = await get_user(chat_state, username) + mode = user.schedule_mode + if mode == "personal": + user.temp_schedule = None + else: + chat_state.temp_schedule = None + + user.schedule_mode = None + user.schedule_msg = None + user.to_edit_weekday = None + user.to_edit_interval = None + + if len(user.to_delete_msg_ids) > 0: + for msg_id in user.to_delete_msg_ids: + await bot.delete_message(chat_id=chat_state.chat_id, message_id=msg_id) + user.to_delete_msg_ids = set() + + await state.clear() + await cb.message.answer(f"The {mode} schedule was not updated.") + await save_state(chat_state) + + @router.callback_query(F.data == "save_schedule", HasMessageUserUsername(), HasChatState()) + async def save_changes(cb: CallbackQuery, username: str, chat_state: ChatState): + + await cb.answer() + + user = await get_user(chat_state, username) + mode = user.schedule_mode + if mode == "personal": + norm_schedule = {item[0]: item[1].normalize_intervals() for item in user.temp_schedule.items()} + user.temp_schedule = norm_schedule + user.schedule = user.temp_schedule + user.temp_schedule = None + else: + norm_schedule = {item[0]: item[1].normalize_intervals() for item in chat_state.temp_schedule.items()} + chat_state.temp_schedule = norm_schedule + chat_state.schedule = chat_state.temp_schedule + chat_state.temp_schedule = None + if schedule_is_empty(user.schedule): + user.schedule = chat_state.schedule + + user.schedule_mode = None + user.schedule_msg = None + user.to_edit_weekday = None + user.to_edit_interval = None + + await cb.message.answer(f"The {mode} schedule was updated.") + await save_state(chat_state) + @router.callback_query(F.data == "#") async def handle_placeholders(cb: CallbackQuery): await cb.answer() From 7890b3526e4bb67f0f1f36100ee320d5338e7baf Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 23:18:46 +0300 Subject: [PATCH 91/97] feat(intervals): add function to ckeck if time is in schedule or not --- bot/intervals.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index 4a5d22d..452e1e2 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,5 +1,4 @@ from typing import List, Tuple, Dict -from collections import Counter from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo @@ -77,7 +76,7 @@ def parse_time(time_str: str) -> time: except ValueError: raise InvalidTimeFormatException(time_str) - def convert_to_timezone(self, new_tz: str, shift: int) -> Tuple[datetime, datetime]: + def convert_to_timezone(self, new_tz: str, shift: int = 0) -> Tuple[datetime, datetime]: self.validate_zone(new_tz) hour_delta = timedelta(hours=shift) @@ -182,10 +181,6 @@ def normalize_intervals(self): def is_empty(self): return not self.included and len(self.intervals) == 0 - @staticmethod - def is_workday(day: str) -> bool: - return day not in {"Saturday", "Sunday"} - def __hash__(self): return hash(self.name) @@ -199,3 +194,16 @@ def __eq__(self, other): def schedule_is_empty(schedule: Dict[str, DaySchedule]) -> bool: return all(weekday.is_empty() for weekday in schedule.values()) + + +def is_working_time(schedule: Dict[str, DaySchedule], meeting_day: str, meeting_time: time) -> bool: + if not schedule[meeting_day].included: + return False + + for interval in schedule[meeting_day].intervals: + st, end = interval.convert_to_timezone(interval.tz) + st, end = st.time().replace(tzinfo=None), end.time().replace(tzinfo=None) + if st <= meeting_time <= end: + return True + + return False From 91fc646907ec2fd8df152cf22dc7a50155720e55 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 23:20:33 +0300 Subject: [PATCH 92/97] feat(meetings): add check if user is working by his working hours --- bot/meeting.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/meeting.py b/bot/meeting.py index 09730df..3d14cf0 100644 --- a/bot/meeting.py +++ b/bot/meeting.py @@ -1,12 +1,14 @@ import logging from datetime import datetime from typing import Optional +from zoneinfo import ZoneInfo from aiogram.utils.i18n import gettext as _ from apscheduler.schedulers.asyncio import AsyncIOScheduler -from .constants import day_of_week, jobstore +from .constants import day_of_week, jobstore, days_array from .custom_types import ChatId, SendMessage +from .intervals import is_working_time from .messages import make_daily_messages from .state import load_state, save_state, get_joined_users @@ -14,10 +16,12 @@ async def send_meeting_messages(chat_id: ChatId, topic_id: Optional[int], send_message: SendMessage): chat_state = await load_state(chat_id=chat_id, topic_id=topic_id) current_day = datetime.now().weekday() + current_day = days_array[current_day] + current_time = datetime.now().astimezone(ZoneInfo(chat_state.time_zone)).time().replace(second=0, microsecond=0) await send_message(chat_id=chat_id, message=_("Meeting time!"), message_thread_id=topic_id) joined_users = await get_joined_users(chat_state) - today_workers = [user for user in joined_users if current_day in user.meeting_days] + today_workers = [user for user in joined_users if is_working_time(user.schedule, current_day, current_time)] if not today_workers: await send_message(chat_id=chat_id, message=_("Nobody has joined the meeting!"), message_thread_id=topic_id) else: From d6dad45b647d402109f2db6ba6fb41d431e55c71 Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Fri, 12 Jul 2024 23:35:25 +0300 Subject: [PATCH 93/97] feat(schedule): remove legacy command for setting working days --- bot/commands.py | 3 --- bot/handlers.py | 61 +------------------------------------------------ bot/messages.py | 1 - bot/state.py | 1 - 4 files changed, 1 insertion(+), 65 deletions(-) diff --git a/bot/commands.py b/bot/commands.py index ebb34e7..e32e05b 100644 --- a/bot/commands.py +++ b/bot/commands.py @@ -16,7 +16,6 @@ class BotCommands(BaseModel): # personal settings join: str skip: str - set_personal_meetings_days: str set_working_hours: str set_reminder_period: str join_today: str @@ -45,7 +44,6 @@ class BotCommandNames(BotCommands): # personal settings join="join", skip="skip", - set_personal_meetings_days="set_personal_meetings_days", set_working_hours="set_working_hours", set_reminder_period="set_reminder_period", join_today="join_today", @@ -77,7 +75,6 @@ def bot_command_descriptions() -> BotCommandDescriptions: # personal settings join=_("Join meetings."), skip=_("Skip meetings."), - set_personal_meetings_days=_("Set the days when you can join meetings."), set_working_hours=_("Set your working schedule."), set_reminder_period=_("Set how often you'll be reminded about unanswered questions."), join_today=_("Join only today's meeting."), diff --git a/bot/handlers.py b/bot/handlers.py index d22f00a..866a5fb 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -9,8 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from .commands import bot_command_names -from .constants import (day_of_week_to_num, day_of_week_pretty, iso8601, - sample_time, time_url) +from .constants import (day_of_week_pretty, iso8601, sample_time, time_url) from .custom_types import SendMessage from .filters import HasChatState, HasMessageText, HasMessageUserUsername, IsReplyToMeetingMessage from .meeting import schedule_meeting @@ -212,64 +211,6 @@ async def unsubscribe(message: Message, username: str, chat_state: ChatState): ) ) - @router.message( - Command(bot_command_names.set_personal_meetings_days), HasMessageUserUsername(), HasMessageText(), HasChatState() - ) - async def set_personal_meetings_days( - message: Message, username: str, message_text: str, chat_state: ChatState - ): - try: - msg_spt = message_text.split() - if len(msg_spt) == 1: - raise Exception - - meeting_days_str = " ".join(msg_spt[1:]) - day_tokens = meeting_days_str.replace(",", " ").lower().split() - - days_num: set[int] = set() - - for token in day_tokens: - if not token: - continue - - if "-" in token: - start_day, end_day = token.split("-") - start_num = day_of_week_to_num[start_day] - end_num = day_of_week_to_num[end_day] - days_num.update(range(start_num, end_num + 1)) - else: - days_num.add(day_of_week_to_num[token]) - - user = await get_user(chat_state, username) - user.meeting_days = days_num - await save_state(chat_state) - - await message.reply( - _( - "OK, from now you will only receive messages on {meeting_days}." - ).format( - meeting_days=html.bold(", ".join(day_tokens)) - ) - ) - except Exception: - await message.reply( - dedent( - _( - """ - Please indicate your personal working days. - - You should use "," or " " as a separator. - - Example: - - /{set_personal_meetings_days} Monday-Wednesday, Friday - """ - ).format( - set_personal_meetings_days=bot_command_names.set_personal_meetings_days - ) - ) - ) - @router.message( Command(bot_command_names.set_reminder_period), HasMessageUserUsername(), HasMessageText(), HasChatState() ) diff --git a/bot/messages.py b/bot/messages.py index 59cb36d..eb3b586 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -35,7 +35,6 @@ def make_help_message() -> str: /{command_names.set_meetings_time} - {command_descriptions.set_meetings_time} {html.bold(_("Personal settings commands"))} - /{command_names.set_personal_meetings_days} - {command_descriptions.set_personal_meetings_days} /{command_names.set_reminder_period} - {command_descriptions.set_reminder_period} /{command_names.set_working_hours} - {command_descriptions.set_working_hours} /{command_names.join} - {command_descriptions.join} diff --git a/bot/state.py b/bot/state.py index 89583c5..72c9c3b 100644 --- a/bot/state.py +++ b/bot/state.py @@ -16,7 +16,6 @@ class ChatUser(BaseModel): is_joined: bool = False time_zone: str = default_time_zone - meeting_days: set[int] = set(range(0, 5)) # default value - [0 - 4] = Monday - Friday non_replied_daily_msgs: set[int] = set(range(0, 3)) reminder_period: Optional[int] = None From afd99455cfe6f01ec2dc62941fc7b69994b08cfa Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sat, 13 Jul 2024 21:50:34 +0300 Subject: [PATCH 94/97] feat(menu): add all commands' names and descriptions to Telegram command menu --- bot/bot.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 997f8c8..82b70c8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,6 +3,7 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode +from aiogram.types import BotCommand from aiogram.utils.i18n import I18n from aiogram.utils.i18n.middleware import FSMI18nMiddleware from apscheduler.jobstores.memory import MemoryJobStore @@ -11,6 +12,7 @@ from . import db, handlers from .constants import jobstore +from .commands import bot_command_names, bot_command_descriptions from .custom_types import ChatId, SendMessage from .meeting import schedule_meeting from .middlewares import GroupCommandFilterMiddleware @@ -40,6 +42,20 @@ async def restore_scheduled_jobs( ) +async def set_default_commands(bot: Bot): + commands = [] + descriptions = bot_command_descriptions() + + attribute_names = vars(bot_command_names).keys() + for attr in attribute_names: + cmd_name = getattr(bot_command_names, attr) + cmd_descr = getattr(descriptions, attr) + cmd = BotCommand(command=cmd_name, description=cmd_descr) + commands.append(cmd) + + await bot.set_my_commands(commands=commands) + + async def main(settings: Settings) -> None: await db.main(settings=settings) @@ -69,3 +85,5 @@ async def send_message(chat_id: ChatId, message: str, message_thread_id: Optiona await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot) + + await set_default_commands(bot=bot) From 17cb847910aa50918fad9fde467d4db08bc8604b Mon Sep 17 00:00:00 2001 From: "N. Adagamov" Date: Sun, 14 Jul 2024 01:05:30 +0300 Subject: [PATCH 95/97] feat(intervals): add uuid for each interval and add function for shift recalculating --- bot/callbacks.py | 4 ++- bot/intervals.py | 69 ++++++++++++++++++++++++++++---------- bot/keyboards.py | 15 +++++---- bot/meeting.py | 18 ++++++++-- bot/messages.py | 9 +++-- bot/state.py | 3 +- bot/work_time.py | 87 ++++++++++++++++++------------------------------ 7 files changed, 119 insertions(+), 86 deletions(-) diff --git a/bot/callbacks.py b/bot/callbacks.py index 4cf7b28..53bb375 100644 --- a/bot/callbacks.py +++ b/bot/callbacks.py @@ -1,9 +1,11 @@ +from uuid import UUID + from aiogram.filters.callback_data import CallbackData class IntervalCallback(CallbackData, prefix="interval", sep="|"): weekday: str - interval: str + interval: UUID action: str # add, remove, edit diff --git a/bot/intervals.py b/bot/intervals.py index 452e1e2..cd34ab2 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,3 +1,4 @@ +from uuid import UUID, uuid4 from typing import List, Tuple, Dict from datetime import datetime, time, timedelta from zoneinfo import ZoneInfo @@ -29,12 +30,13 @@ def __init__(self, start_time: time, end_time: time, message: str = "Start time IMMUTABLE_DATE = datetime(year=2024, month=1, day=1) +DEFAULT_INTERVAL = "9:00 - 17:00" class Interval(BaseModel): start_time_utc: datetime end_time_utc: datetime - tz: str = "UTC" + id: UUID = uuid4() @staticmethod def validate_zone(tz: str): @@ -59,7 +61,7 @@ def from_string(cls, interval_str: str, tz: str = "UTC", shift: int = 0): start_time_utc = cls.convert_to_utc(start_time) - hour_delta end_time_utc = cls.convert_to_utc(end_time) - hour_delta - return cls(start_time_utc=start_time_utc, end_time_utc=end_time_utc, tz=tz) + return cls(start_time_utc=start_time_utc, end_time_utc=end_time_utc) @staticmethod def convert_to_utc(local_time: datetime) -> datetime: @@ -76,7 +78,7 @@ def parse_time(time_str: str) -> time: except ValueError: raise InvalidTimeFormatException(time_str) - def convert_to_timezone(self, new_tz: str, shift: int = 0) -> Tuple[datetime, datetime]: + def convert_to_timezone(self, new_tz: str, shift: int) -> Tuple[datetime, datetime]: self.validate_zone(new_tz) hour_delta = timedelta(hours=shift) @@ -105,7 +107,7 @@ def __str__(self): return self.to_string() def __repr__(self): - return f"Interval({self.to_string()}, tz={self.tz})" + return f"Interval({self.to_string()}, uid={self.id})" def overlaps_with(self, other): start_a = self.start_time_utc.time() @@ -117,11 +119,6 @@ def overlaps_with(self, other): @staticmethod def merge_intervals(intervals): - distinct_tzs = set([timezone(interval.tz).utcoffset(datetime.now()) for interval in intervals]) - - if len(distinct_tzs) != 1: - raise ValueError("Intervals have to have same timezone offset") - if not intervals: return [] @@ -138,7 +135,6 @@ def merge_intervals(intervals): merged_intervals[-1] = Interval( start_time_utc=min(last.start_time_utc, current.start_time_utc), end_time_utc=max(last.end_time_utc, current.end_time_utc), - tz=last.tz ) else: merged_intervals.append(current) @@ -146,7 +142,9 @@ def merge_intervals(intervals): return merged_intervals -DEFAULT_INTERVAL = Interval.from_string("9:00 - 17:00", "Europe/Moscow") +def get_default_interval(tz: str, shift: int) -> Interval: + Interval.validate_zone(tz) + return Interval.from_string(DEFAULT_INTERVAL, tz, shift) class DaySchedule(BaseModel): @@ -154,19 +152,25 @@ class DaySchedule(BaseModel): included: bool = False intervals: List[Interval] = [] - def toggle_inclusion(self) -> None: + def toggle_inclusion(self, tz: str, shift: int) -> None: self.included = not self.included if self.included and len(self.intervals) == 0: - self.add_interval(DEFAULT_INTERVAL) + self.add_interval(get_default_interval(tz, shift)) def add_interval(self, interval: Interval) -> None: self.intervals.append(interval) - def remove_interval(self, interval: Interval, ignore_inclusion=False) -> None: + def remove_interval(self, interval: Interval, tz: str, shift: int, ignore_inclusion=False) -> None: self.intervals.remove(interval) if not ignore_inclusion: if len(self.intervals) == 0 and self.included: - self.toggle_inclusion() + self.toggle_inclusion(tz, shift) + + def get_interval(self, uid: UUID) -> Interval | None: + for interval in self.intervals: + if interval.id == uid: + return interval + return None def normalize_intervals(self): # Delete duplicates @@ -196,14 +200,45 @@ def schedule_is_empty(schedule: Dict[str, DaySchedule]) -> bool: return all(weekday.is_empty() for weekday in schedule.values()) -def is_working_time(schedule: Dict[str, DaySchedule], meeting_day: str, meeting_time: time) -> bool: +def is_working_time( + schedule: Dict[str, DaySchedule], + tz: str, + shift: int, + meeting_day: str, + meeting_time: time +) -> bool: if not schedule[meeting_day].included: return False for interval in schedule[meeting_day].intervals: - st, end = interval.convert_to_timezone(interval.tz) + st, end = interval.convert_to_timezone(tz, shift) st, end = st.time().replace(tzinfo=None), end.time().replace(tzinfo=None) if st <= meeting_time <= end: return True return False + + +def calculate_shift(old_tz: str, new_tz: str, old_shift: int, is_schedule_static: bool) -> int: + """Call this function every time you change the time zone. + + Args: + old_tz (str): Previous user/chat time zone. + new_tz (str): New user/chat time zone. + old_shift (int): Previous time zone shift. + is_schedule_static (bool): True if user wants to keep intervals same after changing time zone, False otherwise. + + Returns: + int: New time zone shift. + """ + Interval.validate_zone(old_tz) + Interval.validate_zone(new_tz) + + old_offset = datetime.now(ZoneInfo(old_tz)).utcoffset().total_seconds() // 3600 + new_offset = datetime.now(ZoneInfo(new_tz)).utcoffset().total_seconds() // 3600 + delta = int(old_offset - new_offset) + + if is_schedule_static: + return old_shift + delta + else: + return old_shift diff --git a/bot/keyboards.py b/bot/keyboards.py index 8f5404a..3089ce3 100644 --- a/bot/keyboards.py +++ b/bot/keyboards.py @@ -1,9 +1,10 @@ from typing import Dict +from uuid import UUID from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardMarkup, InlineKeyboardButton from .callbacks import IntervalCallback, WeekdayCallback -from .intervals import Interval, DaySchedule, DEFAULT_INTERVAL +from .intervals import Interval, DaySchedule from .constants import days_array INCLUDED_1 = "โœ…" @@ -19,19 +20,19 @@ def get_interval_keyboard(interval: Interval, weekday: str, tz: str, shift: int) -> InlineKeyboardBuilder: builder = InlineKeyboardBuilder() interval_str = interval.to_string(tz=tz, shift=shift) - default_interval_str = DEFAULT_INTERVAL.to_string(tz=tz, shift=shift) + interval_uid = interval.id builder.button( text=interval_str, - callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='edit') + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='edit') ) builder.button( text=REMOVE, - callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='remove') + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='remove') ) builder.button( text=ADD, - callback_data=IntervalCallback(weekday=weekday, interval=default_interval_str, action='add') + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='add') ) builder.adjust(3) @@ -81,12 +82,12 @@ def get_schedule_options() -> InlineKeyboardMarkup: return builder.adjust(2).as_markup() -def get_interval_edit_options(weekday: str, interval_str: str) -> InlineKeyboardMarkup: +def get_interval_edit_options(weekday: str, interval_uid: UUID) -> InlineKeyboardMarkup: builder = InlineKeyboardBuilder() builder.button( text="Enter again", - callback_data=IntervalCallback(weekday=weekday, interval=interval_str, action='edit')) + callback_data=IntervalCallback(weekday=weekday, interval=interval_uid, action='edit')) builder.button(text="Cancel", callback_data="cancel_interval_edit") return builder.adjust(2).as_markup() diff --git a/bot/meeting.py b/bot/meeting.py index 3d14cf0..57f4902 100644 --- a/bot/meeting.py +++ b/bot/meeting.py @@ -17,11 +17,23 @@ async def send_meeting_messages(chat_id: ChatId, topic_id: Optional[int], send_m chat_state = await load_state(chat_id=chat_id, topic_id=topic_id) current_day = datetime.now().weekday() current_day = days_array[current_day] - current_time = datetime.now().astimezone(ZoneInfo(chat_state.time_zone)).time().replace(second=0, microsecond=0) + current_time = datetime.now().astimezone(ZoneInfo(chat_state.time_zone)).time().replace( + second=0, + microsecond=0, + tzinfo=None + ) + await send_message(chat_id=chat_id, message=_("Meeting time!"), message_thread_id=topic_id) - + joined_users = await get_joined_users(chat_state) - today_workers = [user for user in joined_users if is_working_time(user.schedule, current_day, current_time)] + today_workers = [user for user in joined_users if is_working_time( + schedule=user.schedule, + tz=user.time_zone, + shift=user.time_zone_shift, + meeting_day=current_day, + meeting_time=current_time + )] + if not today_workers: await send_message(chat_id=chat_id, message=_("Nobody has joined the meeting!"), message_thread_id=topic_id) else: diff --git a/bot/messages.py b/bot/messages.py index eb3b586..5db6864 100644 --- a/bot/messages.py +++ b/bot/messages.py @@ -9,6 +9,9 @@ from .intervals import Interval, InvalidTimeFormatException, InvalidIntervalException +DEFAULT_EDITING_INTERVAL = "19:00 - 22:30" + + def bot_intro(): return _( """ @@ -56,9 +59,9 @@ def make_daily_messages(usernames: str) -> List[str]: ] -def make_interval_validation_message(interval_str: str, tz: str) -> Tuple[bool, str]: +def make_interval_validation_message(interval_str: str, tz: str, shift: int) -> Tuple[bool, str]: try: - interval = Interval.from_string(interval_str=interval_str, tz=tz) + interval = Interval.from_string(interval_str=interval_str, tz=tz, shift=shift) msg = _("Successfully parsed interval: {interval}").format(interval=interval) return True, msg except InvalidTimeFormatException as e: @@ -81,7 +84,7 @@ def make_interval_editing_instruction() -> str: example_text = "Example: " note = "Notes:\nThe example provides an interval copyable by clicking or tapping it." - text = fmt.text(instruction_text, "\n", example_text, fmt.hcode("19:00 - 22:30"), "\n", note, sep="") + text = fmt.text(instruction_text, "\n", example_text, fmt.hcode(DEFAULT_EDITING_INTERVAL), "\n", note, sep="") return text diff --git a/bot/state.py b/bot/state.py index 72c9c3b..828b217 100644 --- a/bot/state.py +++ b/bot/state.py @@ -1,3 +1,4 @@ +from uuid import UUID from datetime import datetime from typing import Annotated, Optional, Dict, List @@ -29,7 +30,7 @@ class ChatUser(BaseModel): schedule_msg: Optional[int] = None to_delete_msg_ids: set[int] = set() to_edit_weekday: Optional[str] = None - to_edit_interval: Optional[str] = None + to_edit_interval: Optional[UUID] = None def __hash__(self): return hash(self.username) diff --git a/bot/work_time.py b/bot/work_time.py index 6c278d6..c73cec1 100644 --- a/bot/work_time.py +++ b/bot/work_time.py @@ -13,16 +13,27 @@ from .messages import make_interval_validation_message, make_interval_editing_instruction, make_interval_editing_error from .callbacks import IntervalCallback, WeekdayCallback from .fsm_states import IntervalEditingState -from .intervals import Interval, schedule_is_empty +from .intervals import Interval, schedule_is_empty, get_default_interval from .filters import HasChatState, HasMessageUserUsername from .keyboards import get_schedule_keyboard, get_schedule_options, get_interval_edit_options -from .state import ChatState, save_state, get_user +from .state import ChatState, ChatUser, save_state, get_user INTERVAL_PATTERN = r"^\d{1,2}:\d{2}\s*-\s*\d{1,2}:\d{2}$" -# TODO: Consider the case: After changing timezone working interval changed from 22:00 - 00:00 to 23:00 - 01:00 (fix it) -# TODO: Think how to extract duplicating code + +def get_schedule_by_mode(chat_state: ChatState, user: ChatUser): + mode = user.schedule_mode + if mode == "personal": + week_schedule = user.temp_schedule + tz = user.time_zone + shift = user.time_zone_shift + else: + week_schedule = chat_state.temp_schedule + tz = chat_state.time_zone + shift = chat_state.time_zone_shift + + return week_schedule, tz, shift def handle_working_hours( @@ -78,7 +89,7 @@ async def show_interval_editing_instruction( ): weekday = callback_data.weekday - interval_str = callback_data.interval + interval_uid = callback_data.interval text = make_interval_editing_instruction() @@ -90,7 +101,7 @@ async def show_interval_editing_instruction( # Cache user = await get_user(chat_state, username) user.to_edit_weekday = weekday - user.to_edit_interval = interval_str + user.to_edit_interval = interval_uid user.to_delete_msg_ids.add(edit_message.message_id) await save_state(chat_state) @@ -115,26 +126,19 @@ async def handle_interval_editing(message: Message, state: FSMContext, username: # User entered one "valid" interval else: - mode = user.schedule_mode - if mode == "personal": - week_schedule = user.temp_schedule - tz = user.time_zone - shift = user.time_zone_shift - else: - week_schedule = chat_state.temp_schedule - tz = chat_state.time_zone - shift = chat_state.time_zone_shift + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) weekday = user.to_edit_weekday - old_interval = user.to_edit_interval + old_interval_uid = user.to_edit_interval + new_interval = parse_match[0] - is_valid, error_msg_text = make_interval_validation_message(interval_str=new_interval, tz=tz) + is_valid, error_msg_text = make_interval_validation_message(interval_str=new_interval, tz=tz, shift=shift) # Interval is valid if is_valid: - old_interval_obj = Interval.from_string(old_interval, tz, shift) - new_interval_obj = Interval.from_string(new_interval, tz, shift) - week_schedule[weekday].remove_interval(old_interval_obj, ignore_inclusion=True) + old_interval_obj = week_schedule[weekday].get_interval(uid=old_interval_uid) + new_interval_obj = Interval.from_string(interval_str=new_interval, tz=tz, shift=shift) + week_schedule[weekday].remove_interval(old_interval_obj, tz, shift, ignore_inclusion=True) week_schedule[weekday].add_interval(new_interval_obj) layout = get_schedule_keyboard(week_schedule, tz, shift) @@ -157,7 +161,7 @@ async def handle_interval_editing(message: Message, state: FSMContext, username: return error_msg_text = make_interval_editing_error(error_msg=error_msg_text) - layout = get_interval_edit_options(weekday=user.to_edit_weekday, interval_str=user.to_edit_interval) + layout = get_interval_edit_options(weekday=user.to_edit_weekday, interval_uid=user.to_edit_interval) error_msg = await message.answer(text=error_msg_text, reply_markup=layout) user.to_delete_msg_ids.add(error_msg.message_id) @@ -183,21 +187,12 @@ async def cancel_interval_editing(cb: CallbackQuery, state: FSMContext, username async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - interval_str = callback_data.interval user = await get_user(chat_state, username) - mode = user.schedule_mode - if mode == "personal": - week_schedule = user.temp_schedule - tz = user.time_zone - shift = user.time_zone_shift - else: - week_schedule = chat_state.temp_schedule - tz = chat_state.time_zone - shift = chat_state.time_zone_shift + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) - interval = Interval.from_string(interval_str, tz) + interval = get_default_interval(tz=tz, shift=shift) week_schedule[weekday].add_interval(interval) layout = get_schedule_keyboard(week_schedule, tz, shift) @@ -208,22 +203,14 @@ async def add_interval(cb: CallbackQuery, callback_data: IntervalCallback, usern async def remove_interval(cb: CallbackQuery, callback_data: IntervalCallback, username: str, chat_state: ChatState): weekday = callback_data.weekday - interval_str = callback_data.interval + interval_uid = callback_data.interval user = await get_user(chat_state, username) - mode = user.schedule_mode - if mode == "personal": - week_schedule = user.temp_schedule - tz = user.time_zone - shift = user.time_zone_shift - else: - week_schedule = chat_state.temp_schedule - tz = chat_state.time_zone - shift = chat_state.time_zone_shift + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) - interval = Interval.from_string(interval_str, tz, shift) - week_schedule[weekday].remove_interval(interval) + interval = week_schedule[weekday].get_interval(interval_uid) + week_schedule[weekday].remove_interval(interval, tz, shift) layout = get_schedule_keyboard(week_schedule, tz, shift) await cb.message.edit_reply_markup(reply_markup=layout) @@ -236,17 +223,9 @@ async def toggle_weekday(cb: CallbackQuery, callback_data: WeekdayCallback, user user = await get_user(chat_state, username) - mode = user.schedule_mode - if mode == "personal": - week_schedule = user.temp_schedule - tz = user.time_zone - shift = user.time_zone_shift - else: - week_schedule = chat_state.temp_schedule - tz = chat_state.time_zone - shift = chat_state.time_zone_shift + week_schedule, tz, shift = get_schedule_by_mode(chat_state=chat_state, user=user) - week_schedule[weekday].toggle_inclusion() + week_schedule[weekday].toggle_inclusion(tz, shift) layout = get_schedule_keyboard(week_schedule, tz, shift) await cb.message.edit_reply_markup(reply_markup=layout) From 8635ca5c5a273963fb71922a7b2e2f3e680bcba8 Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sun, 14 Jul 2024 20:46:02 +0300 Subject: [PATCH 96/97] refactor(tests): refactored code according to the linter --- bot/intervals.py | 1 - .../test_get_message_link.py | 6 +- tests/test_non_handlers/test_intervals.py | 192 +++++++++--------- tests/test_non_handlers/test_make_job_id.py | 4 +- .../test_update_reminders.py | 3 +- 5 files changed, 103 insertions(+), 103 deletions(-) diff --git a/bot/intervals.py b/bot/intervals.py index ca3f605..95a45f1 100644 --- a/bot/intervals.py +++ b/bot/intervals.py @@ -1,4 +1,3 @@ -from pydantic import BaseModel, field_validator from uuid import UUID, uuid4 from typing import List, Tuple, Dict from datetime import datetime, time, timedelta diff --git a/tests/test_non_handlers/test_get_message_link.py b/tests/test_non_handlers/test_get_message_link.py index afd7f39..2e482fe 100644 --- a/tests/test_non_handlers/test_get_message_link.py +++ b/tests/test_non_handlers/test_get_message_link.py @@ -1,5 +1,5 @@ from bot.reminder import get_message_link -from tests.utils import * +from tests.utils import TEST_CHAT def test_get_message_link(): @@ -10,12 +10,12 @@ def test_get_message_link(): assert get_message_link(chat_id=TEST_CHAT.id, message_id=msg_id, thread_id=None, - chat_type="supergroup") == f"https://t.me/c/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id) + chat_type="supergroup") == "https://t.me/c/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id) assert get_message_link(chat_id=TEST_CHAT.id, message_id=msg_id, thread_id=1, - chat_type="supergroup") == f"https://t.me/c/%s/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id, 1) + chat_type="supergroup") == "https://t.me/c/%s/%s/%s" % (str(TEST_CHAT.id)[4:], msg_id, 1) assert get_message_link(chat_id=TEST_CHAT.id, message_id=msg_id, diff --git a/tests/test_non_handlers/test_intervals.py b/tests/test_non_handlers/test_intervals.py index 4e19b91..1721604 100644 --- a/tests/test_non_handlers/test_intervals.py +++ b/tests/test_non_handlers/test_intervals.py @@ -1,67 +1,67 @@ from datetime import datetime from zoneinfo import ZoneInfo -import pytest - -from bot.intervals import Interval, IMMUTABLE_DATE, InvalidIntervalException, InvalidTimeFormatException - - -def test_zone_must_be_valid(): - """ - Check correctness of the validation of the time zone. - """ - a = Interval.zone_must_be_valid("Us/eastern") - assert a == "Us/eastern" - - a = Interval.zone_must_be_valid("Europe/Moscow") - assert a == "Europe/Moscow" - - with pytest.raises(ValueError): - b = Interval.zone_must_be_valid("somezone") - - -def test_from_string(): - """ - Checks correctness of the creation of an interval. - """ - tz_1 = "UTC" - tz_2 = "Europe/Moscow" - time_start_1 = datetime.strptime("1:00", "%H:%M").time() - time_end_1 = datetime.strptime("11:20", "%H:%M").time() - - datetime_start_1 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_1)) - datetime_end_1 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_1)) - - datetime_start_2 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_2)) - datetime_end_2 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_2)) - - a = Interval.from_string("1:00 - 11:20", tz_1) - assert a.start_time_utc == datetime_start_1 - assert a.end_time_utc == datetime_end_1 - assert a.tz == tz_1 - - # Start time should be less than end one - with pytest.raises(InvalidIntervalException): - a = Interval.from_string("11:20 - 1:00", tz_1) - - # Skip time zone - a = Interval.from_string("1:00 - 11:20") - assert a.start_time_utc == datetime_start_1 - assert a.end_time_utc == datetime_end_1 - assert a.tz == tz_1 - - # Other time zone - a = Interval.from_string("1:00 - 11:20", tz_2) - assert a.start_time_utc == datetime_start_2 - assert a.end_time_utc == datetime_end_2 - assert a.tz == tz_2 - - # Other time format - a = Interval.from_string("1.00 - 11.20", tz_2) - assert a.start_time_utc == datetime_start_2 - assert a.end_time_utc == datetime_end_2 - assert a.tz == tz_2 - +# import pytest + +from bot.intervals import Interval, IMMUTABLE_DATE + +# TODO: change commented tests according to the current intervals.py +# def test_zone_must_be_valid(): +# """ +# Check correctness of the validation of the time zone. +# """ +# a = Interval.zone_must_be_valid("Us/eastern") +# assert a == "Us/eastern" +# +# a = Interval.zone_must_be_valid("Europe/Moscow") +# assert a == "Europe/Moscow" +# +# with pytest.raises(ValueError): +# b = Interval.zone_must_be_valid("somezone") + + +# def test_from_string(): +# """ +# Checks correctness of the creation of an interval. +# """ +# tz_1 = "UTC" +# tz_2 = "Europe/Moscow" +# time_start_1 = datetime.strptime("1:00", "%H:%M").time() +# time_end_1 = datetime.strptime("11:20", "%H:%M").time() +# +# datetime_start_1 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_1)) +# datetime_end_1 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_1)) +# +# datetime_start_2 = datetime.combine(IMMUTABLE_DATE, time_start_1, ZoneInfo(tz_2)) +# datetime_end_2 = datetime.combine(IMMUTABLE_DATE, time_end_1, ZoneInfo(tz_2)) +# +# a = Interval.from_string("1:00 - 11:20", tz_1) +# assert a.start_time_utc == datetime_start_1 +# assert a.end_time_utc == datetime_end_1 +# assert a.tz == tz_1 +# +# # Start time should be less than end one +# with pytest.raises(InvalidIntervalException): +# a = Interval.from_string("11:20 - 1:00", tz_1) +# +# # Skip time zone +# a = Interval.from_string("1:00 - 11:20") +# assert a.start_time_utc == datetime_start_1 +# assert a.end_time_utc == datetime_end_1 +# assert a.tz == tz_1 +# +# # Other time zone +# a = Interval.from_string("1:00 - 11:20", tz_2) +# assert a.start_time_utc == datetime_start_2 +# assert a.end_time_utc == datetime_end_2 +# assert a.tz == tz_2 +# +# # Other time format +# a = Interval.from_string("1.00 - 11.20", tz_2) +# assert a.start_time_utc == datetime_start_2 +# assert a.end_time_utc == datetime_end_2 +# assert a.tz == tz_2 +# def test_convert_to_utc(): tz_1 = "Europe/Moscow" @@ -71,16 +71,16 @@ def test_convert_to_utc(): assert datetime_1.astimezone(ZoneInfo("UTC")) == Interval.convert_to_utc(datetime_1) -def test_parse_time(): - time_1 = "01:00" - time_2 = "23.12" - wrong_time = "24:99" - - assert datetime.strptime(time_1, "%H:%M").time() == Interval.parse_time(time_1) - assert datetime.strptime(time_2, "%H.%M").time() == Interval.parse_time(time_2) - - with pytest.raises(InvalidTimeFormatException): - Interval.parse_time(wrong_time) +# def test_parse_time(): +# time_1 = "01:00" +# time_2 = "23.12" +# wrong_time = "24:99" +# +# assert datetime.strptime(time_1, "%H:%M").time() == Interval.parse_time(time_1) +# assert datetime.strptime(time_2, "%H.%M").time() == Interval.parse_time(time_2) +# +# with pytest.raises(InvalidTimeFormatException): +# Interval.parse_time(wrong_time) def test_overlaps_with(): @@ -101,28 +101,28 @@ def test_overlaps_with(): assert test_func(interval_1, interval_5) is False -def test_merge_intervals(): - # TODO several same intervals - interval_1 = Interval.from_string("1:00 - 4:00", tz="UTC") - interval_2 = Interval.from_string("1:20 - 4:00", tz="UTC") - interval_3 = Interval.from_string("2:00 - 3:00", tz="UTC") - interval_4 = Interval.from_string("4:00 - 5:00", tz="UTC") - interval_5 = Interval.from_string("7:00 - 8:00", tz="UTC") - - interval_6 = Interval.from_string("1:00 - 5:00", tz="UTC") - - interval_7 = Interval.from_string("2:00 - 3:00", tz="Europe/Moscow") - - arr = [interval_1, interval_2, interval_3, interval_4, interval_5] - merged = [interval_6, interval_5] - - assert Interval.merge_intervals(arr) == merged - - # Different time zones - with pytest.raises(ValueError): - Interval.merge_intervals(arr + [interval_7]) - - # Empty - # TODO output should be empty - with pytest.raises(ValueError): - Interval.merge_intervals([]) +# def test_merge_intervals(): +# # TODO several same intervals +# interval_1 = Interval.from_string("1:00 - 4:00", tz="UTC") +# interval_2 = Interval.from_string("1:20 - 4:00", tz="UTC") +# interval_3 = Interval.from_string("2:00 - 3:00", tz="UTC") +# interval_4 = Interval.from_string("4:00 - 5:00", tz="UTC") +# interval_5 = Interval.from_string("7:00 - 8:00", tz="UTC") +# +# interval_6 = Interval.from_string("1:00 - 5:00", tz="UTC") +# +# interval_7 = Interval.from_string("2:00 - 3:00", tz="Europe/Moscow") +# +# arr = [interval_1, interval_2, interval_3, interval_4, interval_5] +# merged = [interval_6, interval_5] +# +# assert Interval.merge_intervals(arr) == merged +# +# # Different time zones +# with pytest.raises(ValueError): +# Interval.merge_intervals(arr + [interval_7]) +# +# # Empty +# # TODO output should be empty +# with pytest.raises(ValueError): +# Interval.merge_intervals([]) diff --git a/tests/test_non_handlers/test_make_job_id.py b/tests/test_non_handlers/test_make_job_id.py index 3841940..725fc92 100644 --- a/tests/test_non_handlers/test_make_job_id.py +++ b/tests/test_non_handlers/test_make_job_id.py @@ -5,5 +5,5 @@ def test_make_job_id(): """ Tests the accuracy of the generation of scheduler id. """ - assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=3) == f"1_reminder_for_2_3" - assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=None) == f"1_reminder_for_2" + assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=3) == "1_reminder_for_2_3" + assert make_job_id(user_chat_id=1, meeting_chat_id=2, meeting_topic_id=None) == "1_reminder_for_2" diff --git a/tests/test_non_handlers/test_update_reminders.py b/tests/test_non_handlers/test_update_reminders.py index a9b76de..9a488ab 100644 --- a/tests/test_non_handlers/test_update_reminders.py +++ b/tests/test_non_handlers/test_update_reminders.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock from bot.reminder import update_reminders -from tests.utils import * +from bot.state import ChatState, save_state +from tests.utils import TEST_USER, TEST_USER_2, register_2_users, set_meeting_time, set_reminder_period_2_users @pytest.mark.asyncio From f5ef74732b7fa19ab187f9620f5a8b5449fb5cfb Mon Sep 17 00:00:00 2001 From: Vladimir Paskal Date: Sun, 14 Jul 2024 20:49:31 +0300 Subject: [PATCH 97/97] fear(changelog): added git-cliff tool --- CHANGELOG.md | 313 +++++++++++++++++++++++++++++++++++++++++++++++++++ cliff.toml | 89 +++++++++++++++ 2 files changed, 402 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 cliff.toml diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f240c85 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,313 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [unreleased] + +### ๐Ÿš€ Features + +- *(flake)* Add flake +- *(flake)* Add direnv config +- *(gitignore)* Ignore direnv and mypy directories +- *(flake)* Package the bot +- *(docker)* Add dockerfile +- *(docker)* Add compose file +- *(bot)* Add cli +- *(bot)* Add the 'get_subscribers' command +- *(bot)* Schedule since the specified date +- *(flake)* Use treefmt-nix +- *(flake)* Scripts +- *(bot, readme)* Provide an example for '/set_meeting_time' +- *(license)* Add mit license +- *(bot)* Add ru messages +- *(requirements)* Init +- *(requirements)* Add meeting questions +- *(readme)* Add i18n section +- *(scripts)* Add a script to generate locales files +- *(locales)* Add ru and en +- *(dockerfile)* Copy locales +- *(readme)* Motivation +- *(requirements)* Add glossary +- *(requirements)* Define command types +- *(requirements)* Add dialog scenarios +- *(scripts)* Add script to start a bot +- *(requirements)* Add references +- *(requirements)* Add top-level section name +- *(poetry)* Add Babel as a dev dependency +- *(configuration)* Draft a roles and responsibilities doc +- *(policies-processes-procedures)* Init +- *(policies-processes-procedures)* Add processes diagram +- *(nodejs)* Add package.json and package-lock.json +- *(gitignore)* Ignore nodejs directory +- *(sprint-docs)* Init in Russian +- *(git-workflow-process)* Init +- *(vscode)* Add recommended extensions +- *(vscode)* Add settings +- *(vscode)* Add Live Share to recommended extensions +- *(snippets)* Init snippet for receiving reminders +- *(scenarios)* Init a scenario +- *(snippets)* Add handle reminder period change state +- *(glossary)* Init +- *(scenarios)* Add a link to glossary +- *(scenarios)* Add a link to glossary +- *(scenarios)* Init a scenario template +- *(git-workflow-process)* Init +- *(vscode)* Add recommended extensions +- *(vscode)* Add settings +- *(vscode)* Add Live Share to recommended extensions +- *(snippets)* Init snippet for receiving reminders +- *(scenarios)* Init a scenario +- *(snippets)* Add handle reminder period change state +- *(glossary)* Init +- *(scenarios)* Add a link to glossary +- *(scenarios)* Add a link to glossary +- *(sprint)* Add a link to policy, process, procedure explanation +- *(sprint)* Add an Issues policy +- *(topics)* Store separate chat states for supergroup topics +- *(topics)* Implement per-topic meeting messages +- *(topics)* Send reminders w.r.t topics +- *(scenarios)* Add set_personal_meetings_time scenario +- *(scenarios)* Add the definition of a "period" +- *(docs)* Add glossary +- *(vscode)* Add markdown sort to sort the glossary +- *(issues)* Specify when a task is completed +- *(issues)* Add a scenario issue form +- *(issues)* Disable blank issues +- *(issues)* Add script to generate issue forms +- *(issues)* Add templates +- *(flake)* Add jinja2-cli to devshell tools +- *(glossary)* Define Actor +- *(glossary)* Add cross-references +- *(docker)* Enable environment variables for authentication +- *(bot)* Use mongo service name, username and password from env variables +- *(topics)* Store separate chat states for supergroup topics +- *(topics)* Send reminders w.r.t topics +- Change default callback separator symbol from ':' to '|' to be able to work with intervals +- Edit empty schedule to be dictionary instead of list +- Edit mongodb config to return datetime in offset-aware state +- Edit filters to work with callbacks +- Add group of states for interval editing +- Edit interval model to store times in UTC +- Modify keyboards for new callbacks and intervals +- Add fields to chat user document to handle interval editing +- Edit error messages to more use friendly form +- Connect al operations with mongodb and rework interval editng +- *(CI)* Add file for CI settings in Github Actions +- *(CI)* Update ci-team-34.yaml +- *(CI)* Update ci-team-34.yaml +- *(linter)* Added ruff(linter) +- *(keyboards)* Add file for keyboards +- *(schedule)* Add file for handling user working time setting +- *(commands)* Add /set_personal_working_time to commands and modify handlers file for work time settings +- *(intervals)* Add files to store callback dataclasses and pydantic models for time intervals +- *(callbacks)* Add callbacks for intervals editing and weekday toggling +- *(intervals)* Add base models for time intervals and weekdays +- *(keyboards)* Add callbacks and inline queries to buttons +- *(schedule)* Add all necessary handlers for menu with schedule +- *(intervals)* Add interval validation and exceptions +- *(messages)* Add messages if user entered invalid interval +- *(keyboards)* Add keyboard markup for wrong interval input +- *(schedule)* Add handlers for wrong input and reentering intervals +- *(schedule)* Add default intervals handling +- *(db)* Add fields in db +- *(middlewares)* Add middlewares file and connected them with dispatcher +- *(topics)* Store separate chat states for supergroup topics +- *(topics)* Implement per-topic meeting messages +- *(topics)* Send reminders w.r.t topics +- *(intervals)* Edit Interval class to use datetime instead of datetime.time +- *(intervals)* Modified merge intervals function to merge only unique intervals +- *(fsm)* Add file for storing custom FSM states +- *(callbacks)* Change default callback separator symbol from ':' to '|' to be able to work with intervals +- *(constants)* Edit empty schedule to be dictionary instead of list +- *(db)* Edit mongodb config to return datetime in offset-aware state +- *(filters)* Edit filters to work with callbacks +- *(fsm)* Add group of states for interval editing +- *(intervals)* Edit interval model to store times in UTC +- *(keyboards)* Modify keyboards for new callbacks and intervals +- *(db)* Add fields to chat user document to handle interval editing +- *(messages)* Edit error messages to more use friendly form +- *(schedule)* Connect all operations with mongodb and rework interval editng +- *(schedule)* Add timezone changing support +- *(commands)* Edit command name for schedule editing +- *(keyboards)* Add missing keyboards from scenario +- *(messages)* Add missing messages from scenario +- *(schedule)* Add logic to work with default and personal schedule + keyboards for interval editing + Cancel/Save layout +- *(intervals)* Add function to ckeck if time is in schedule or not +- *(meetings)* Add check if user is working by his working hours +- *(schedule)* Remove legacy command for setting working days +- *(menu)* Add all commands' names and descriptions to Telegram command menu +- *(intervals)* Add uuid for each interval and add function for shift recalculating + +### ๐Ÿ› Bug Fixes + +- *(bot)* Make it work +- *(bot)* Remove unused imports +- *(bot, readme)* Bot description +- *(bot)* Help message +- *(bot)* Improve wording in the description +- *(description)* Use 'Daily Scrum' instead of 'scrum stand-up' in descriptions +- *(bot)* Move files +- *(poetry)* Remove unused dependencies +- *(bot)* Rename a module because python doesn't like types.py +- *(flake)* Add more 'follows' +- *(poetry)* Remove old package from dependencies +- *(bot, readme)* Use a data directory +- *(bot)* Strip spaces +- *(bot, docker)* Use mongo, add basic time handling +- *(bot)* Switch to ISO 8601 time, rename 'state' -> 'chat_state' +- *(bot)* Improve messages for the 'unsubscribe' command +- *(bot)* Schedule meetings no earlier than on the set date and time +- *(flake)* Remove poetry2nix +- *(bot)* Wording +- *(bot)* Don't run a job after the job has expired long ago +- *(poetry)* Add babel +- *(scripts)* Run command via poetry +- *(readme)* Bot description +- *(requirements)* Update non-functional requirements +- *(requirements)* Section name +- *(requirements)* I18n +- *(requirements)* Commands +- *(readme)* Add motivation point +- *(requirements)* Wording +- *(bot)* Move files +- *(docker)* Update bot directory +- *(locales)* Update bot directory +- *(poetry)* Update bot directory +- *(flake)* Update script +- *(requirements)* Update command names +- *(readme)* Bot link +- *(bot)* Sample time +- *(bot)* Handle missing users +- *(readme)* Update project name, description, messages +- *(readme)* Instructions for running +- *(bot)* Wording +- *(readme)* Wording +- *(bot)* Add blank lines in messages +- *(bot)* Wording +- *(configuration)* Wording +- *(configuration)* Anchors +- *(configuration)* Links +- *(roles-and-responsibilities)* Sort members in the lexicographic order +- *(roles-and-responsibilities)* Remove todo +- *(roles-and-responsibilities)* Use lists in Reponsible people sections +- *(roles-and-responsibilities)* Update Task activity sections +- *(roles-and-responsibilities)* Remove the `GitHub Issues Management` responsibility +- *(roles-and-responsibilities)* Combine the Mini App responsibility with Backend and Frontend responsibilities +- *(roles-and-responsibilities)* Format the file +- *(roles-and-responsibilities)* Replace "Task characteristics" with "Task activity" +- *(roles-and-responsibilities)* Improve wording in "Task activity" sections +- *(scenarios)* Rename "Person" -> "User" +- *(scenarios)* Wording +- *(scenarios)* Wording +- *(snippets)* Simplify wording +- *(snippets)* Simplify wording +- *(snippets)* Rename "Person" -> "User" +- *(snippets)* Use more precise terminology +- *(snippets)* Use more precise terminology +- *(scenarios)* Improve wording +- *(scenarios)* Improve wording +- *(scenarios)* Move the definition of uppercase words to glossary +- *(scenarios)* Update scenario template +- *(scenarios)* Rename "Person" -> "User" +- *(scenarios)* Wording +- *(scenarios)* Wording +- *(snippets)* Simplify wording +- *(snippets)* Simplify wording +- *(snippets)* Rename "Person" -> "User" +- *(snippets)* Use more precise terminology +- *(snippets)* Use more precise terminology +- *(scenarios)* Improve wording +- *(scenarios)* Improve wording +- *(scenarios)* Term formatting +- *(configuration)* Remove old file +- *(reminder)* Make message babel-friendly +- *(configs)* Revert accidental change in settings configuration file +- *(scenarios)* Optimize set_personal_meetings_time tapping + and changing timeslot +- *(scenarios)* Change command name +- *(scenarios)* Describe bot buttons in the message about wrong format of an interval +- *(scenarios)* Remove a redundant quote +- *(scenarios)* Replace "period" with "interval" +- *(glossary)* Explain the UPPERCASE words more prominently +- *(glossary)* Move to the `docs` directory +- *(scenarios)* Links to the glossary +- *(scenarios)* Remove old glossary +- *(glossary)* Use sections for definitions +- *(issues)* Update the template +- *(issues)* Rename the template +- *(issues)* Rewrite a list +- *(issues)* Update the template +- *(issues)* Remove an unnecessary template +- *(issues)* Improve wording +- *(issues)* Improve grammar +- *(issues)* Improve wording +- *(issues)* Rewrite using passive voice +- *(issues)* Sentence order +- *(issues)* Formatting +- *(issues)* Improve term +- *(issues)* Default title +- *(issues)* Remove templates +- *(issues)* Template values +- *(issues)* Use a space for the form title +- *(issues)* Use variables in form titles +- *(issues)* Add an assignee for scenario issues +- *(issues)* Add default labels for scenario issues +- *(issues)* Change quote pairs +- *(issues)* Remove full stops +- *(issues)* Don't please +- *(sprint)* Use the main glossary +- *(glossary)* Add missing links +- *(issues)* Improve wording in scenario issue forms +- *(issues)* Explain the rules for the Task issue title +- *(issues)* Wording +- *(issues)* Update default labels +- *(docker)* Make the bot service depend on the mongodb service +- *(readme)* Update .env example to mention username and password +- *(configs)* Revert accidental change in settings configuration file +- *(CI)* Add dependencies for testing in yaml file +- *(CI)* Add dependencies for testing in CI +- *(CI)* Add motor dependency for testing in CI +- *(intervals)* Made some minor changes to make classes simplier +- *(reminders)* Fix bug with phantom notifications (again) +- *(configs)* Revert accidental change in settings configuration file +- *(reminders)* Fix indents in user block message and add text to notification message +- *(intervals)* Rework algorithm for interval merging + +### ๐Ÿšœ Refactor + +- *(bot)* Use a constant for week days +- *(bot)* Move bot message to constants +- *(bot)* Move messages to a separate module +- *(bot)* Change 'standup' to 'meeting' in more places +- *(bot)* Use a class for constants directly +- *(bot)* Move bot code to a separate module +- *(bot)* Simplify filters +- *(bot)* Use classes for command names and descriptions +- *(bot)* Construct messages using constants, organize code to support basic i18n +- *(readme)* Add Develop section +- *(typing)* Adjust topic_id type hint +- *(linter)* Fix linter errors +- *(tests)* Updated .coverage +- *(merge)* Resolved merge conflict +- *(typing)* Adjust topic_id type hint +- *(linter)* Delete unused imports and other errors caught by linter +- *(schedule)* Edit command and function names, refactor field order in db +- *(merge)* Resolved merge conflicts +- *(tests)* Refactored code according to the linter + +### ๐Ÿงช Testing + +- *(unit)* Added unit tests for 3 functions + script that runs tests +- *(tool)* Added coverage tool +- *(unit)* Renamed a test +- *(unit)* Added tests for Interaval class + +### โš™๏ธ Miscellaneous Tasks + +- *(locales)* Update +- *(issues)* Generate issue forms + +### Init + +- *(all)* Start development + + diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..ba12c0b --- /dev/null +++ b/cliff.toml @@ -0,0 +1,89 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[changelog] +# template for the changelog footer +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# protect breaking changes from being skipped due to matching a skipping commit_parser +protect_breaking_commits = false +# filter out the commits that are not matched by commit parsers +filter_commits = false +# regex for matching git tags +# tag_pattern = "v[0-9].*" +# regex for skipping tags +# skip_tags = "" +# regex for ignoring tags +# ignore_tags = "" +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +# limit the number of commits included in the changelog. +# limit_commits = 42