├── aiogram_calendar ├── tests │ ├── __init__.py │ ├── test_dialog_calendar.py │ └── test_simple_calendar.py ├── __init__.py ├── schemas.py ├── common.py ├── simple_calendar.py └── dialog_calendar.py ├── requirements.txt ├── setup.cfg ├── requirements_dev.txt ├── .gitignore ├── Makefile ├── LICENSE.txt ├── pyproject.toml ├── README.md └── example_bot.py /aiogram_calendar/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiogram~=3.7 2 | aiogram_calendar -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | license_files = LICENSE.txt -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | aiogram~=3.7 2 | aiogram_calendar 3 | flake8 4 | pytest 5 | pytest-cov 6 | pytest-asyncio -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .backup/ 2 | .vscode/ 3 | env/ 4 | .venv/ 5 | .env 6 | .coverage 7 | .pypirc 8 | __pycache__/ 9 | *.pyc 10 | config.py 11 | 12 | dist/ 13 | aiogram_calendar.egg-info 14 | MANIFEST 15 | MANIFEST.in 16 | 17 | build/ 18 | *.egg-info/ 19 | -------------------------------------------------------------------------------- /aiogram_calendar/__init__.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from aiogram_calendar.common import get_user_locale 3 | from aiogram_calendar.simple_calendar import SimpleCalendar 4 | from aiogram_calendar.dialog_calendar import DialogCalendar 5 | from aiogram_calendar.schemas import SimpleCalendarCallback, DialogCalendarCallback, CalendarLabels 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | example: 2 | python example_bot.py 3 | 4 | # to run exact test use: 5 | # make tests m=aiogram_calendar/tests/test_dialog_calendar.py 6 | tests: 7 | pytest $m --capture=tee-sys 8 | 9 | dev: 10 | pip install -r requirements_dev.txt 11 | 12 | undev: 13 | pip uninstall -y -r requirements_dev.txt 14 | 15 | .PHONY: build publish 16 | 17 | build: 18 | python -m build 19 | 20 | publish: 21 | python -m twine upload dist/* 22 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | Copyright (c) 2018 noXplode 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | The above copyright notice and this permission notice shall be included in all 10 | copies or substantial portions of the Software. 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0.0", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "aiogram_calendar" 7 | version = "0.6.0" 8 | description = "Simple Inline Calendar & Date Selection tool for Aiogram Telegram bots" 9 | readme = "README.md" 10 | authors = [{ name = "Andrii Nikolabai", email = "nikolabay.as@gmail.com" }] 11 | license = { file = "LICENSE.txt" } 12 | classifiers = [ 13 | 'Development Status :: 3 - Alpha', 14 | 'Intended Audience :: Developers', 15 | 'Topic :: Software Development :: Libraries :: Python Modules', 16 | 'License :: OSI Approved :: MIT License', 17 | 'Programming Language :: Python :: 3', 18 | 'Programming Language :: Python :: 3.9', 19 | 'Programming Language :: Python :: 3.10', 20 | 'Programming Language :: Python :: 3.11', 21 | 'Programming Language :: Python :: 3.12', 22 | 'Programming Language :: Python :: 3.13' 23 | ] 24 | keywords = ['Aiogram', 'Telegram', 'Bots', 'Calendar'] 25 | dependencies = [ 26 | 'aiogram>=3.7' 27 | ] 28 | requires-python = ">=3.9" 29 | 30 | [project.optional-dependencies] 31 | dev = ["pytest", "pytest-asyncio"] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/noXplode/aiogram_calendar" 35 | -------------------------------------------------------------------------------- /aiogram_calendar/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from enum import Enum 3 | 4 | from pydantic import BaseModel, conlist, Field 5 | 6 | from aiogram.filters.callback_data import CallbackData 7 | 8 | 9 | class SimpleCalAct(str, Enum): 10 | ignore = 'IGNORE' 11 | prev_y = 'PREV-YEAR' 12 | next_y = 'NEXT-YEAR' 13 | prev_m = 'PREV-MONTH' 14 | next_m = 'NEXT-MONTH' 15 | cancel = 'CANCEL' 16 | today = 'TODAY' 17 | day = 'DAY' 18 | 19 | 20 | class DialogCalAct(str, Enum): 21 | ignore = 'IGNORE' 22 | set_y = 'SET-YEAR' 23 | set_m = 'SET-MONTH' 24 | prev_y = 'PREV-YEAR' 25 | next_y = 'NEXT-YEAR' 26 | cancel = 'CANCEL' 27 | start = 'START' 28 | day = 'SET-DAY' 29 | 30 | 31 | class CalendarCallback(CallbackData, prefix="calendar"): 32 | act: str 33 | year: Optional[int] = None 34 | month: Optional[int] = None 35 | day: Optional[int] = None 36 | 37 | 38 | class SimpleCalendarCallback(CalendarCallback, prefix="simple_calendar"): 39 | act: SimpleCalAct 40 | 41 | 42 | class DialogCalendarCallback(CalendarCallback, prefix="dialog_calendar"): 43 | act: DialogCalAct 44 | 45 | 46 | class CalendarLabels(BaseModel): 47 | "Schema to pass labels for calendar. Can be used to put in different languages" 48 | days_of_week: conlist(str, max_length=7, min_length=7) = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"] 49 | months: conlist(str, max_length=12, min_length=12) = [ 50 | "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" 51 | ] 52 | cancel_caption: str = Field(default='Cancel', description='Caprion for Cancel button') 53 | today_caption: str = Field(default='Today', description='Caprion for Cancel button') 54 | 55 | 56 | HIGHLIGHT_FORMAT = "[{}]" 57 | 58 | 59 | def highlight(text): 60 | return HIGHLIGHT_FORMAT.format(text) 61 | 62 | 63 | def superscript(text): 64 | normal = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-=()" 65 | super_s = "ᴬᴮᶜᴰᴱᶠᴳᴴᴵᴶᴷᴸᴹᴺᴼᴾQᴿˢᵀᵁⱽᵂˣʸᶻᵃᵇᶜᵈᵉᶠᵍʰᶦʲᵏˡᵐⁿᵒᵖ۹ʳˢᵗᵘᵛʷˣʸᶻ⁰¹²³⁴⁵⁶⁷⁸⁹⁺⁻⁼⁽⁾" 66 | output = '' 67 | for i in text: 68 | output += (super_s[normal.index(i)] if i in normal else i) 69 | return output 70 | 71 | 72 | def subscript(text): 73 | normal = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-=()" 74 | sub_s = "ₐ₈CDₑբGₕᵢⱼₖₗₘₙₒₚQᵣₛₜᵤᵥwₓᵧZₐ♭꜀ᑯₑբ₉ₕᵢⱼₖₗₘₙₒₚ૧ᵣₛₜᵤᵥwₓᵧ₂₀₁₂₃₄₅₆₇₈₉₊₋₌₍₎" 75 | output = '' 76 | for i in text: 77 | output += (sub_s[normal.index(i)] if i in normal else i) 78 | return output 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | # Date Selection tool for Aiogram Telegram Bots 5 | 6 | 7 | 8 | 9 | 10 | ## Description 11 | 12 | 13 | 14 | A simple inline calendar, date selection tool for [aiogram](https://github.com/aiogram/aiogram) telegram bots written in Python. 15 | 16 | 17 | 18 | Offers two types of date pickers: 19 | 20 | 21 | 22 | Navigation calendar - user can either select a date or move to the next or previous month/year by clicking a singe button. 23 | 24 | 25 | 26 | Dialog calendar - user selects year on first stage, month on next stage, day on last stage. 27 | 28 | 29 | 30 | 31 | 32 | **From version 0.2 supports aiogram 3, use version 0.1.1 with aiogram 2.** 33 | **From version 0.6 supports aiogram 3.7, use version 0.5 with aiogram <3.7 ** 34 | 35 | 36 | ## Main features 37 | - Two calendars with abilities to navigate years, months, days altogether or in dialog 38 | - Ability to set specified locale (language of captions) or inherit from user`s locale 39 | - Limiting the range of dates to select from 40 | - Highlighting todays date 41 | 42 | 43 | ## Usage 44 | 45 | 46 | 47 | Install package 48 | 49 | 50 | 51 | 52 | 53 | pip install aiogram_calendar 54 | 55 | 56 | 57 | 58 | 59 | A full working example on how to use aiogram-calendar is provided in `*bot_example.py*`. 60 | 61 | 62 | 63 | 64 | 65 | In example keyboard with buttons is created. 66 | 67 | 68 | 69 | Each button triggers a calendar in a different way by adding it to a message with a *reply_markup*. 70 | 71 | 72 | 73 | reply_markup=await SimpleCalendar().start_calendar() 74 | 75 | ^^ will reply with a calendar created using English localization (months and days of week captions). Locale can be overridden by passing locale argument: 76 | 77 | 78 | 79 | reply_markup=await SimpleCalendar(locale='uk_UA').start_calendar() 80 | 81 | or by getting locale from User data provided by telegram API using get_user_locale method by passing `message.from_user` to it 82 | 83 | 84 | 85 | reply_markup=await SimpleCalendar(locale=await get_user_locale(message.from_user)).start_calendar() 86 | 87 | 88 | 89 | Depending on what button of calendar user will press callback is precessed using the *process_selection* method. 90 | 91 | 92 | 93 | selected, date = await SimpleCalendar(locale=await get_user_locale(callback_query.from_user)).process_selection(callback_query, callback_data) 94 | 95 | Here locale is specified from `callback_query.from_user` 96 | 97 | 98 | 99 | 100 | 101 | ## Gif demo: 102 | 103 | 104 | 105 | 106 | 107 | ![aiogram_calendar](https://j.gifs.com/nRQlqW.gif) -------------------------------------------------------------------------------- /aiogram_calendar/common.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | import locale 3 | 4 | from aiogram.types import User 5 | from datetime import datetime 6 | 7 | from .schemas import CalendarLabels 8 | 9 | 10 | async def get_user_locale(from_user: User) -> str: 11 | "Returns user locale in format en_US, accepts User instance from Message, CallbackData etc" 12 | loc = from_user.language_code 13 | return locale.locale_alias[loc].split(".")[0] 14 | 15 | 16 | class GenericCalendar: 17 | 18 | def __init__( 19 | self, 20 | locale: str = None, 21 | cancel_btn: str = None, 22 | today_btn: str = None, 23 | show_alerts: bool = False 24 | ) -> None: 25 | """Pass labels if you need to have alternative language of buttons 26 | 27 | Parameters: 28 | locale (str): Locale calendar must have captions in (in format uk_UA), if None - default English will be used 29 | cancel_btn (str): label for button Cancel to cancel date input 30 | today_btn (str): label for button Today to set calendar back to todays date 31 | show_alerts (bool): defines how the date range error would shown (defaults to False) 32 | """ 33 | self._labels = CalendarLabels() 34 | if locale: 35 | # getting month names and days of week in specified locale 36 | with calendar.different_locale(locale): 37 | self._labels.days_of_week = list(calendar.day_abbr) 38 | self._labels.months = calendar.month_abbr[1:] 39 | 40 | if cancel_btn: 41 | self._labels.cancel_caption = cancel_btn 42 | if today_btn: 43 | self._labels.today_caption = today_btn 44 | 45 | self.min_date = None 46 | self.max_date = None 47 | self.show_alerts = show_alerts 48 | 49 | def set_dates_range(self, min_date: datetime, max_date: datetime): 50 | """Sets range of minimum & maximum dates""" 51 | self.min_date = min_date 52 | self.max_date = max_date 53 | 54 | async def process_day_select(self, data, query): 55 | """Checks selected date is in allowed range of dates""" 56 | date = datetime(int(data.year), int(data.month), int(data.day)) 57 | if self.min_date and self.min_date > date: 58 | await query.answer( 59 | f'The date have to be later {self.min_date.strftime("%d/%m/%Y")}', 60 | show_alert=self.show_alerts 61 | ) 62 | return False, None 63 | elif self.max_date and self.max_date < date: 64 | await query.answer( 65 | f'The date have to be before {self.max_date.strftime("%d/%m/%Y")}', 66 | show_alert=self.show_alerts 67 | ) 68 | return False, None 69 | await query.message.delete_reply_markup() # removing inline keyboard 70 | return True, date 71 | -------------------------------------------------------------------------------- /aiogram_calendar/tests/test_dialog_calendar.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from unittest.mock import AsyncMock 3 | 4 | import pytest 5 | 6 | from aiogram_calendar import DialogCalendar 7 | from aiogram_calendar.schemas import DialogCalendarCallback 8 | from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton 9 | 10 | 11 | def test_init(): 12 | dialog = DialogCalendar() 13 | assert dialog 14 | 15 | 16 | # checking that overall structure of returned object is correct 17 | @pytest.mark.asyncio 18 | async def test_start_calendar(): 19 | result = await DialogCalendar().start_calendar() 20 | 21 | assert isinstance(result, InlineKeyboardMarkup) 22 | assert result.row_width == 5 23 | 24 | assert result.inline_keyboard 25 | kb = result.inline_keyboard 26 | assert isinstance(kb, list) 27 | 28 | for i in range(0, len(kb)): 29 | assert isinstance(kb[i], list) 30 | 31 | assert isinstance(kb[0][1], InlineKeyboardButton) 32 | year = datetime.now().year 33 | assert kb[0][0].text == str(year - 2) 34 | assert isinstance(kb[0][0].callback_data, str) 35 | 36 | 37 | # checking if we can pass different years start period to check the range of buttons 38 | testset = [ 39 | (2020, 2018, 2022), 40 | (None, datetime.now().year - 2, datetime.now().year + 2), 41 | ] 42 | 43 | 44 | @pytest.mark.asyncio 45 | @pytest.mark.parametrize("year, expected1, expected2", testset) 46 | async def test_start_calendar_params(year, expected1, expected2): 47 | if year: 48 | result = await DialogCalendar().start_calendar(year=year) 49 | else: 50 | result = await DialogCalendar().start_calendar() 51 | kb = result.inline_keyboard 52 | assert kb[0][0].text == str(expected1) 53 | assert kb[0][4].text == str(expected2) 54 | 55 | 56 | testset = [ 57 | (DialogCalendarCallback(**{'act': 'IGNORE', 'year': '2022', 'month': '8', 'day': '0'}), (False, None)), 58 | ( 59 | DialogCalendarCallback(**{'act': 'SET-DAY', 'year': '2022', 'month': '8', 'day': '1'}), 60 | (True, datetime(2022, 8, 1)) 61 | ), 62 | ( 63 | DialogCalendarCallback(**{'act': 'SET-DAY', 'year': '2021', 'month': '7', 'day': '16'}), 64 | (True, datetime(2021, 7, 16)) 65 | ), 66 | ( 67 | DialogCalendarCallback(**{'act': 'SET-DAY', 'year': '1900', 'month': '10', 'day': '8'}), 68 | (True, datetime(1900, 10, 8)) 69 | ), 70 | (DialogCalendarCallback(**{'act': 'PREV-YEAR', 'year': '2022', 'month': '8', 'day': '1'}), (False, None)), 71 | (DialogCalendarCallback(**{'act': 'NEXT-YEAR', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 72 | (DialogCalendarCallback(**{'act': 'SET-MONTH', 'year': '2022', 'month': '8', 'day': '1'}), (False, None)), 73 | (DialogCalendarCallback(**{'act': 'SET-YEAR', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 74 | (DialogCalendarCallback(**{'act': 'START', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 75 | (DialogCalendarCallback(**{'act': 'CANCEL', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 76 | ] 77 | 78 | 79 | @pytest.mark.asyncio 80 | @pytest.mark.parametrize("callback_data, expected", testset) 81 | async def test_process_selection(callback_data, expected): 82 | query = AsyncMock() 83 | result = await DialogCalendar().process_selection(query=query, data=callback_data) 84 | assert result == expected 85 | -------------------------------------------------------------------------------- /aiogram_calendar/tests/test_simple_calendar.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import datetime 3 | from unittest.mock import AsyncMock 4 | 5 | import pytest 6 | 7 | from aiogram_calendar import SimpleCalendar 8 | from aiogram_calendar.schemas import SimpleCalendarCallback 9 | from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton 10 | 11 | 12 | def test_init(): 13 | assert SimpleCalendar() 14 | 15 | 16 | # checking that overall structure of returned object is correct 17 | @pytest.mark.asyncio 18 | async def test_start_calendar(): 19 | result = await SimpleCalendar().start_calendar() 20 | 21 | assert isinstance(result, InlineKeyboardMarkup) 22 | assert result.row_width == 7 23 | assert result.inline_keyboard 24 | kb = result.inline_keyboard 25 | assert isinstance(kb, list) 26 | 27 | for i in range(0, len(kb)): 28 | assert isinstance(kb[i], list) 29 | 30 | assert isinstance(kb[0][1], InlineKeyboardButton) 31 | now = datetime.now() 32 | # also testing here that year will be highlighted with [] 33 | assert kb[0][1].text == f'[{str(now.year)}]' 34 | assert isinstance(kb[0][1].callback_data, str) 35 | 36 | 37 | @pytest.mark.asyncio 38 | async def test_start_calendar_locale(): 39 | result = await SimpleCalendar(locale='uk_UA').start_calendar() 40 | assert result.inline_keyboard[2][0].text in ['Пн', '[Пн]'] 41 | assert result.inline_keyboard[2][6].text in ['Нд', '[Нд]'] 42 | 43 | result = await SimpleCalendar(locale='ru_Ru').start_calendar() 44 | assert result.inline_keyboard[2][0].text in ['Пн', '[Пн]'] 45 | assert result.inline_keyboard[2][6].text in ['Вс', '[Вс]'] 46 | 47 | 48 | # checking if we can pass different years & months as start periods 49 | testset = [ 50 | (2022, 2, '2022', 'Feb'), 51 | (2022, None, '2022', f'{calendar.month_name[datetime.now().month][:3]}'), 52 | # also testing here that year will be highlighted with [] 53 | (None, 5, f'[{datetime.now().year}]', 'May'), 54 | ] 55 | 56 | 57 | @pytest.mark.asyncio 58 | @pytest.mark.parametrize("year, month, expected, expected_2", testset) 59 | async def test_start_calendar_params(year, month, expected, expected_2): 60 | if year and month: 61 | result = await SimpleCalendar().start_calendar(year=year, month=month) 62 | elif year: 63 | result = await SimpleCalendar().start_calendar(year=year) 64 | elif month: 65 | result = await SimpleCalendar().start_calendar(month=month) 66 | kb = result.inline_keyboard 67 | assert kb[0][1].text == expected 68 | assert kb[1][1].text == expected_2 69 | 70 | now = datetime.now() 71 | testset = [ 72 | (SimpleCalendarCallback(**{'act': 'IGNORE', 'year': 2022, 'month': 8, 'day': 0}), (False, None)), 73 | (SimpleCalendarCallback(**{'act': 'DAY', 'year': '2022', 'month': '8', 'day': '1'}), (True, datetime(2022, 8, 1))), 74 | ( 75 | SimpleCalendarCallback(**{'act': 'DAY', 'year': '2021', 'month': '7', 'day': '16'}), 76 | (True, datetime(2021, 7, 16)) 77 | ), 78 | ( 79 | SimpleCalendarCallback(**{'act': 'DAY', 'year': '1900', 'month': '10', 'day': '8'}), 80 | (True, datetime(1900, 10, 8)) 81 | ), 82 | (SimpleCalendarCallback(**{'act': 'PREV-YEAR', 'year': '2022', 'month': '8', 'day': '1'}), (False, None)), 83 | (SimpleCalendarCallback(**{'act': 'PREV-MONTH', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 84 | (SimpleCalendarCallback(**{'act': 'NEXT-YEAR', 'year': '2022', 'month': '8', 'day': '1'}), (False, None)), 85 | (SimpleCalendarCallback(**{'act': 'NEXT-MONTH', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 86 | (SimpleCalendarCallback(**{'act': 'CANCEL', 'year': '2021', 'month': '8', 'day': '0'}), (False, None)), 87 | (SimpleCalendarCallback(**{'act': 'TODAY', 'year': '2021', 'month': '8', 'day': '1'}), (False, None)), 88 | ] 89 | 90 | 91 | @pytest.mark.asyncio 92 | @pytest.mark.parametrize("callback_data, expected", testset) 93 | async def test_process_selection(callback_data, expected): 94 | query = AsyncMock() 95 | result = await SimpleCalendar().process_selection(query=query, data=callback_data) 96 | assert result == expected 97 | -------------------------------------------------------------------------------- /example_bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import sys 4 | from datetime import datetime 5 | 6 | from aiogram_calendar import SimpleCalendar, SimpleCalendarCallback, DialogCalendar, DialogCalendarCallback, \ 7 | get_user_locale 8 | from aiogram import Bot, Dispatcher, F 9 | from aiogram.enums import ParseMode 10 | from aiogram.filters import CommandStart 11 | from aiogram.filters.callback_data import CallbackData 12 | from aiogram.types import Message, ReplyKeyboardMarkup, KeyboardButton, CallbackQuery 13 | from aiogram.utils.markdown import hbold 14 | from aiogram.client.default import DefaultBotProperties 15 | 16 | from config import API_TOKEN 17 | 18 | # API_TOKEN = '' uncomment and insert your telegram bot API key here 19 | 20 | # All handlers should be attached to the Router (or Dispatcher) 21 | dp = Dispatcher() 22 | 23 | 24 | # initialising keyboard, each button will be used to start a calendar with different initial settings 25 | kb = [ 26 | [ # 1 row of buttons for Navigation calendar 27 | # where user can go to next/previous year/month 28 | KeyboardButton(text='Navigation Calendar'), 29 | KeyboardButton(text='Navigation Calendar w month'), 30 | ], 31 | [ # 2 row of buttons for Dialog calendar 32 | # where user selects year first, then month, then day 33 | KeyboardButton(text='Dialog Calendar'), 34 | KeyboardButton(text='Dialog Calendar w year'), 35 | KeyboardButton(text='Dialog Calendar w month'), 36 | ], 37 | ] 38 | start_kb = ReplyKeyboardMarkup(keyboard=kb, resize_keyboard=True) 39 | 40 | 41 | # when user sends `/start` command, answering with inline calendar 42 | @dp.message(CommandStart()) 43 | async def command_start_handler(message: Message) -> None: 44 | """ 45 | This handler receives messages with `/start` command 46 | """ 47 | await message.reply(f"Hello, {hbold(message.from_user.full_name)}! Pick a calendar", reply_markup=start_kb) 48 | 49 | 50 | # default way of displaying a selector to user - date set for today 51 | @dp.message(F.text.lower() == 'navigation calendar') 52 | async def nav_cal_handler(message: Message): 53 | await message.answer( 54 | "Please select a date: ", 55 | reply_markup=await SimpleCalendar(locale=await get_user_locale(message.from_user)).start_calendar() 56 | ) 57 | 58 | 59 | # can be launched at specific year and month with allowed dates range 60 | @dp.message(F.text.lower() == 'navigation calendar w month') 61 | async def nav_cal_handler_date(message: Message): 62 | calendar = SimpleCalendar( 63 | locale=await get_user_locale(message.from_user), show_alerts=True 64 | ) 65 | calendar.set_dates_range(datetime(2022, 1, 1), datetime(2025, 12, 31)) 66 | await message.answer( 67 | "Calendar opened on feb 2023. Please select a date: ", 68 | reply_markup=await calendar.start_calendar(year=2023, month=2) 69 | ) 70 | 71 | 72 | # simple calendar usage - filtering callbacks of calendar format 73 | @dp.callback_query(SimpleCalendarCallback.filter()) 74 | async def process_simple_calendar(callback_query: CallbackQuery, callback_data: CallbackData): 75 | calendar = SimpleCalendar( 76 | locale=await get_user_locale(callback_query.from_user), show_alerts=True 77 | ) 78 | calendar.set_dates_range(datetime(2022, 1, 1), datetime(2025, 12, 31)) 79 | selected, date = await calendar.process_selection(callback_query, callback_data) 80 | if selected: 81 | await callback_query.message.answer( 82 | f'You selected {date.strftime("%d/%m/%Y")}', 83 | reply_markup=start_kb 84 | ) 85 | 86 | 87 | @dp.message(F.text.lower() == 'dialog calendar') 88 | async def dialog_cal_handler(message: Message): 89 | await message.answer( 90 | "Please select a date: ", 91 | reply_markup=await DialogCalendar( 92 | locale=await get_user_locale(message.from_user) 93 | ).start_calendar() 94 | ) 95 | 96 | 97 | # starting calendar with year 1989 98 | @dp.message(F.text.lower() == 'dialog calendar w year') 99 | async def dialog_cal_handler_year(message: Message): 100 | await message.answer( 101 | "Calendar opened years selection around 1989. Please select a date: ", 102 | reply_markup=await DialogCalendar( 103 | locale=await get_user_locale(message.from_user) 104 | ).start_calendar(1989) 105 | ) 106 | 107 | 108 | # starting dialog calendar with year 1989 & month 109 | @dp.message(F.text.lower() == 'dialog calendar w month') 110 | async def dialog_cal_handler_month(message: Message): 111 | await message.answer( 112 | "Calendar opened on sep 1989. Please select a date: ", 113 | reply_markup=await DialogCalendar( 114 | locale=await get_user_locale(message.from_user) 115 | ).start_calendar(year=1989, month=9) 116 | ) 117 | 118 | 119 | # dialog calendar usage 120 | @dp.callback_query(DialogCalendarCallback.filter()) 121 | async def process_dialog_calendar(callback_query: CallbackQuery, callback_data: CallbackData): 122 | selected, date = await DialogCalendar( 123 | locale=await get_user_locale(callback_query.from_user) 124 | ).process_selection(callback_query, callback_data) 125 | if selected: 126 | await callback_query.message.answer( 127 | f'You selected {date.strftime("%d/%m/%Y")}', 128 | reply_markup=start_kb 129 | ) 130 | 131 | 132 | async def main() -> None: 133 | # Initialize Bot instance with a default parse mode which will be passed to all API calls 134 | bot = Bot(API_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) # works from aiogram v.3.7.0 135 | 136 | # And the run events dispatching 137 | await dp.start_polling(bot) 138 | 139 | 140 | if __name__ == "__main__": 141 | logging.basicConfig(level=logging.INFO, stream=sys.stdout) 142 | asyncio.run(main()) 143 | -------------------------------------------------------------------------------- /aiogram_calendar/simple_calendar.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import datetime, timedelta 3 | 4 | from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton 5 | from aiogram.types import CallbackQuery 6 | 7 | from .schemas import SimpleCalendarCallback, SimpleCalAct, highlight, superscript 8 | from .common import GenericCalendar 9 | 10 | 11 | class SimpleCalendar(GenericCalendar): 12 | 13 | ignore_callback = SimpleCalendarCallback(act=SimpleCalAct.ignore).pack() # placeholder for no answer buttons 14 | 15 | async def start_calendar( 16 | self, 17 | year: int = datetime.now().year, 18 | month: int = datetime.now().month 19 | ) -> InlineKeyboardMarkup: 20 | """ 21 | Creates an inline keyboard with the provided year and month 22 | :param int year: Year to use in the calendar, if None the current year is used. 23 | :param int month: Month to use in the calendar, if None the current month is used. 24 | :return: Returns InlineKeyboardMarkup object with the calendar. 25 | """ 26 | 27 | today = datetime.now() 28 | now_weekday = self._labels.days_of_week[today.weekday()] 29 | now_month, now_year, now_day = today.month, today.year, today.day 30 | 31 | def highlight_month(): 32 | month_str = self._labels.months[month - 1] 33 | if now_month == month and now_year == year: 34 | return highlight(month_str) 35 | return month_str 36 | 37 | def highlight_weekday(): 38 | if now_month == month and now_year == year and now_weekday == weekday: 39 | return highlight(weekday) 40 | return weekday 41 | 42 | def format_day_string(): 43 | date_to_check = datetime(year, month, day) 44 | if self.min_date and date_to_check < self.min_date: 45 | return superscript(str(day)) 46 | elif self.max_date and date_to_check > self.max_date: 47 | return superscript(str(day)) 48 | return str(day) 49 | 50 | def highlight_day(): 51 | day_string = format_day_string() 52 | if now_month == month and now_year == year and now_day == day: 53 | return highlight(day_string) 54 | return day_string 55 | 56 | # building a calendar keyboard 57 | kb = [] 58 | 59 | # inline_kb = InlineKeyboardMarkup(row_width=7) 60 | # First row - Year 61 | years_row = [] 62 | years_row.append(InlineKeyboardButton( 63 | text="<<", 64 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.prev_y, year=year, month=month, day=1).pack() 65 | )) 66 | years_row.append(InlineKeyboardButton( 67 | text=str(year) if year != now_year else highlight(year), 68 | callback_data=self.ignore_callback 69 | )) 70 | years_row.append(InlineKeyboardButton( 71 | text=">>", 72 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.next_y, year=year, month=month, day=1).pack() 73 | )) 74 | kb.append(years_row) 75 | 76 | # Month nav Buttons 77 | month_row = [] 78 | month_row.append(InlineKeyboardButton( 79 | text="<", 80 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.prev_m, year=year, month=month, day=1).pack() 81 | )) 82 | month_row.append(InlineKeyboardButton( 83 | text=highlight_month(), 84 | callback_data=self.ignore_callback 85 | )) 86 | month_row.append(InlineKeyboardButton( 87 | text=">", 88 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.next_m, year=year, month=month, day=1).pack() 89 | )) 90 | kb.append(month_row) 91 | 92 | # Week Days 93 | week_days_labels_row = [] 94 | for weekday in self._labels.days_of_week: 95 | week_days_labels_row.append( 96 | InlineKeyboardButton(text=highlight_weekday(), callback_data=self.ignore_callback) 97 | ) 98 | kb.append(week_days_labels_row) 99 | 100 | # Calendar rows - Days of month 101 | month_calendar = calendar.monthcalendar(year, month) 102 | 103 | for week in month_calendar: 104 | days_row = [] 105 | for day in week: 106 | if day == 0: 107 | days_row.append(InlineKeyboardButton(text=" ", callback_data=self.ignore_callback)) 108 | continue 109 | days_row.append(InlineKeyboardButton( 110 | text=highlight_day(), 111 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.day, year=year, month=month, day=day).pack() 112 | )) 113 | kb.append(days_row) 114 | 115 | # nav today & cancel button 116 | cancel_row = [] 117 | cancel_row.append(InlineKeyboardButton( 118 | text=self._labels.cancel_caption, 119 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.cancel, year=year, month=month, day=day).pack() 120 | )) 121 | cancel_row.append(InlineKeyboardButton(text=" ", callback_data=self.ignore_callback)) 122 | cancel_row.append(InlineKeyboardButton( 123 | text=self._labels.today_caption, 124 | callback_data=SimpleCalendarCallback(act=SimpleCalAct.today, year=year, month=month, day=day).pack() 125 | )) 126 | kb.append(cancel_row) 127 | return InlineKeyboardMarkup(row_width=7, inline_keyboard=kb) 128 | 129 | async def _update_calendar(self, query: CallbackQuery, with_date: datetime): 130 | await query.message.edit_reply_markup( 131 | reply_markup=await self.start_calendar(int(with_date.year), int(with_date.month)) 132 | ) 133 | 134 | async def process_selection(self, query: CallbackQuery, data: SimpleCalendarCallback) -> tuple: 135 | """ 136 | Process the callback_query. This method generates a new calendar if forward or 137 | backward is pressed. This method should be called inside a CallbackQueryHandler. 138 | :param query: callback_query, as provided by the CallbackQueryHandler 139 | :param data: callback_data, dictionary, set by calendar_callback 140 | :return: Returns a tuple (Boolean,datetime), indicating if a date is selected 141 | and returning the date if so. 142 | """ 143 | return_data = (False, None) 144 | 145 | # processing empty buttons, answering with no action 146 | if data.act == SimpleCalAct.ignore: 147 | await query.answer(cache_time=60) 148 | return return_data 149 | 150 | temp_date = datetime(int(data.year), int(data.month), 1) 151 | 152 | # user picked a day button, return date 153 | if data.act == SimpleCalAct.day: 154 | return await self.process_day_select(data, query) 155 | 156 | # user navigates to previous year, editing message with new calendar 157 | if data.act == SimpleCalAct.prev_y: 158 | prev_date = datetime(int(data.year) - 1, int(data.month), 1) 159 | await self._update_calendar(query, prev_date) 160 | # user navigates to next year, editing message with new calendar 161 | if data.act == SimpleCalAct.next_y: 162 | next_date = datetime(int(data.year) + 1, int(data.month), 1) 163 | await self._update_calendar(query, next_date) 164 | # user navigates to previous month, editing message with new calendar 165 | if data.act == SimpleCalAct.prev_m: 166 | prev_date = temp_date - timedelta(days=1) 167 | await self._update_calendar(query, prev_date) 168 | # user navigates to next month, editing message with new calendar 169 | if data.act == SimpleCalAct.next_m: 170 | next_date = temp_date + timedelta(days=31) 171 | await self._update_calendar(query, next_date) 172 | if data.act == SimpleCalAct.today: 173 | next_date = datetime.now() 174 | if next_date.year != int(data.year) or next_date.month != int(data.month): 175 | await self._update_calendar(query, datetime.now()) 176 | else: 177 | await query.answer(cache_time=60) 178 | if data.act == SimpleCalAct.cancel: 179 | await query.message.delete_reply_markup() 180 | # at some point user clicks DAY button, returning date 181 | return return_data 182 | -------------------------------------------------------------------------------- /aiogram_calendar/dialog_calendar.py: -------------------------------------------------------------------------------- 1 | import calendar 2 | from datetime import datetime 3 | 4 | from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton 5 | from aiogram.types import CallbackQuery 6 | 7 | from .schemas import DialogCalendarCallback, DialogCalAct, highlight, superscript 8 | from .common import GenericCalendar 9 | 10 | 11 | class DialogCalendar(GenericCalendar): 12 | 13 | ignore_callback = DialogCalendarCallback(act=DialogCalAct.ignore).pack() # placeholder for no answer buttons 14 | 15 | async def _get_month_kb(self, year: int): 16 | """Creates an inline keyboard with months for specified year""" 17 | 18 | today = datetime.now() 19 | now_month, now_year = today.month, today.year 20 | now_year = today.year 21 | 22 | kb = [] 23 | # first row with year button 24 | years_row = [] 25 | years_row.append( 26 | InlineKeyboardButton( 27 | text=self._labels.cancel_caption, 28 | callback_data=DialogCalendarCallback(act=DialogCalAct.cancel, year=year, month=1, day=1).pack() 29 | ) 30 | ) 31 | years_row.append(InlineKeyboardButton( 32 | text=str(year) if year != today.year else highlight(year), 33 | callback_data=DialogCalendarCallback(act=DialogCalAct.start, year=year, month=-1, day=-1).pack() 34 | )) 35 | years_row.append(InlineKeyboardButton(text=" ", callback_data=self.ignore_callback)) 36 | kb.append(years_row) 37 | # two rows with 6 months buttons 38 | month6_row = [] 39 | 40 | def highlight_month(): 41 | month_str = self._labels.months[month - 1] 42 | if now_month == month and now_year == year: 43 | return highlight(month_str) 44 | return month_str 45 | 46 | for month in range(1, 7): 47 | month6_row.append(InlineKeyboardButton( 48 | text=highlight_month(), 49 | callback_data=DialogCalendarCallback( 50 | act=DialogCalAct.set_m, year=year, month=month, day=-1 51 | ).pack() 52 | )) 53 | month12_row = [] 54 | 55 | for month in range(7, 13): 56 | month12_row.append(InlineKeyboardButton( 57 | text=highlight_month(), 58 | callback_data=DialogCalendarCallback( 59 | act=DialogCalAct.set_m, year=year, month=month, day=-1 60 | ).pack() 61 | )) 62 | 63 | kb.append(month6_row) 64 | kb.append(month12_row) 65 | return InlineKeyboardMarkup(row_width=6, inline_keyboard=kb) 66 | 67 | async def _get_days_kb(self, year: int, month: int): 68 | """Creates an inline keyboard with calendar days of month for specified year and month""" 69 | 70 | today = datetime.now() 71 | now_weekday = self._labels.days_of_week[today.weekday()] 72 | now_month, now_year, now_day = today.month, today.year, today.day 73 | 74 | def highlight_month(): 75 | month_str = self._labels.months[month - 1] 76 | if now_month == month and now_year == year: 77 | return highlight(month_str) 78 | return month_str 79 | 80 | def highlight_weekday(): 81 | if now_month == month and now_year == year and now_weekday == weekday: 82 | return highlight(weekday) 83 | return weekday 84 | 85 | def format_day_string(): 86 | date_to_check = datetime(year, month, day) 87 | if self.min_date and date_to_check < self.min_date: 88 | return superscript(str(day)) 89 | elif self.max_date and date_to_check > self.max_date: 90 | return superscript(str(day)) 91 | return str(day) 92 | 93 | def highlight_day(): 94 | day_string = format_day_string() 95 | if now_month == month and now_year == year and now_day == day: 96 | return highlight(day_string) 97 | return day_string 98 | 99 | kb = [] 100 | nav_row = [] 101 | nav_row.append( 102 | InlineKeyboardButton( 103 | text=self._labels.cancel_caption, 104 | callback_data=DialogCalendarCallback(act=DialogCalAct.cancel, year=year, month=1, day=1).pack() 105 | ) 106 | ) 107 | nav_row.append(InlineKeyboardButton( 108 | text=str(year) if year != now_year else highlight(year), 109 | callback_data=DialogCalendarCallback(act=DialogCalAct.start, year=year, month=-1, day=-1).pack() 110 | )) 111 | nav_row.append(InlineKeyboardButton( 112 | text=highlight_month(), 113 | callback_data=DialogCalendarCallback(act=DialogCalAct.set_y, year=year, month=-1, day=-1).pack() 114 | )) 115 | kb.append(nav_row) 116 | 117 | week_days_labels_row = [] 118 | for weekday in self._labels.days_of_week: 119 | week_days_labels_row.append(InlineKeyboardButton( 120 | text=highlight_weekday(), callback_data=self.ignore_callback)) 121 | kb.append(week_days_labels_row) 122 | 123 | month_calendar = calendar.monthcalendar(year, month) 124 | 125 | for week in month_calendar: 126 | days_row = [] 127 | for day in week: 128 | if day == 0: 129 | days_row.append(InlineKeyboardButton(text=" ", callback_data=self.ignore_callback)) 130 | continue 131 | days_row.append(InlineKeyboardButton( 132 | text=highlight_day(), 133 | callback_data=DialogCalendarCallback(act=DialogCalAct.day, year=year, month=month, day=day).pack() 134 | )) 135 | kb.append(days_row) 136 | return InlineKeyboardMarkup(row_width=7, inline_keyboard=kb) 137 | 138 | async def start_calendar( 139 | self, 140 | year: int = datetime.now().year, 141 | month: int = None 142 | ) -> InlineKeyboardMarkup: 143 | today = datetime.now() 144 | now_year = today.year 145 | 146 | if month: 147 | return await self._get_days_kb(year, month) 148 | kb = [] 149 | # inline_kb = InlineKeyboardMarkup(row_width=5) 150 | # first row - years 151 | years_row = [] 152 | for value in range(year - 2, year + 3): 153 | years_row.append(InlineKeyboardButton( 154 | text=str(value) if value != now_year else highlight(value), 155 | callback_data=DialogCalendarCallback(act=DialogCalAct.set_y, year=value, month=-1, day=-1).pack() 156 | )) 157 | kb.append(years_row) 158 | # nav buttons 159 | nav_row = [] 160 | nav_row.append(InlineKeyboardButton( 161 | text='<<', 162 | callback_data=DialogCalendarCallback(act=DialogCalAct.prev_y, year=year, month=-1, day=-1).pack() 163 | )) 164 | nav_row.append(InlineKeyboardButton( 165 | text=self._labels.cancel_caption, 166 | callback_data=DialogCalendarCallback(act=DialogCalAct.cancel, year=year, month=1, day=1).pack() 167 | )) 168 | nav_row.append(InlineKeyboardButton( 169 | text='>>', 170 | callback_data=DialogCalendarCallback(act=DialogCalAct.next_y, year=year, month=1, day=1).pack() 171 | )) 172 | kb.append(nav_row) 173 | return InlineKeyboardMarkup(row_width=5, inline_keyboard=kb) 174 | 175 | async def process_selection(self, query: CallbackQuery, data: DialogCalendarCallback) -> tuple: 176 | return_data = (False, None) 177 | if data.act == DialogCalAct.ignore: 178 | await query.answer(cache_time=60) 179 | if data.act == DialogCalAct.set_y: 180 | await query.message.edit_reply_markup(reply_markup=await self._get_month_kb(int(data.year))) 181 | if data.act == DialogCalAct.prev_y: 182 | new_year = int(data.year) - 5 183 | await query.message.edit_reply_markup(reply_markup=await self.start_calendar(year=new_year)) 184 | if data.act == DialogCalAct.next_y: 185 | new_year = int(data.year) + 5 186 | await query.message.edit_reply_markup(reply_markup=await self.start_calendar(year=new_year)) 187 | if data.act == DialogCalAct.start: 188 | await query.message.edit_reply_markup(reply_markup=await self.start_calendar(int(data.year))) 189 | if data.act == DialogCalAct.set_m: 190 | await query.message.edit_reply_markup(reply_markup=await self._get_days_kb(int(data.year), int(data.month))) 191 | if data.act == DialogCalAct.day: 192 | 193 | return await self.process_day_select(data, query) 194 | 195 | if data.act == DialogCalAct.cancel: 196 | await query.message.delete_reply_markup() 197 | return return_data 198 | --------------------------------------------------------------------------------