├── .gitattributes ├── requirements.txt ├── keyboards.py ├── clear_dict.py ├── .gitignore ├── README.md ├── telebot_calendar.py ├── google_sheet.py └── main.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/frolovelo/saloon_bot/HEAD/requirements.txt -------------------------------------------------------------------------------- /keyboards.py: -------------------------------------------------------------------------------- 1 | """ 2 | Создание клавиатуры главного меню и кнопок "Назад"/"Отмена" 3 | """ 4 | from telebot.types import InlineKeyboardMarkup, InlineKeyboardButton 5 | 6 | 7 | def create_markup_menu(): 8 | """ 9 | Создаёт клавиатуру главного меню 10 | 11 | :return: InlineKeyboardMarkup 12 | """ 13 | menu_buttons = ['Запись✅', 'Отмена записи❌', 'Мои записи📝'] 14 | markup = InlineKeyboardMarkup(row_width=2) 15 | markup.add(InlineKeyboardButton(text=menu_buttons[0], callback_data='RECORD')) 16 | markup.add(InlineKeyboardButton(text=menu_buttons[1], callback_data='CANCEL_RECORD')) 17 | markup.add(InlineKeyboardButton(text=menu_buttons[2], callback_data='MY_RECORD')) 18 | 19 | return markup 20 | 21 | 22 | def button_to_menu(return_callback: str | None, return_text='Назад', menu_text='Вернуться в меню') \ 23 | -> list[InlineKeyboardButton]: 24 | """ 25 | Создает кнопки "Назад" и "В главное меню". 26 | 27 | :param return_callback: Callback-данные для кнопки "Назад". 28 | Если значение - None, то кнопка не будет создана. 29 | :param return_text: Текст на кнопке "Назад" (по умолчанию - "Назад") 30 | :param menu_text: Текст на кнопке "В главное меню" (по умолчанию - "Вернуться в меню") 31 | 32 | :return: Список объектов InlineKeyboardButton 33 | """ 34 | if return_callback: 35 | return [InlineKeyboardButton(text=return_text, callback_data=return_callback), 36 | InlineKeyboardButton(text=menu_text, callback_data='MENU')] 37 | return [InlineKeyboardButton(text=menu_text, callback_data='MENU')] 38 | -------------------------------------------------------------------------------- /clear_dict.py: -------------------------------------------------------------------------------- 1 | """ 2 | Хранение информации о пользователе и отчистка 3 | """ 4 | from datetime import datetime, timedelta 5 | from threading import Lock, Thread 6 | from time import sleep 7 | 8 | # хранит объекты GoogleSheet по ключу id 9 | CLIENT_DICT = {} 10 | # хранит название календаря по ключу id 11 | CALENDAR_DICT = {} 12 | # хранит время создания объекта GoogleSheet 13 | TIMER_DICT = {} 14 | # Lock для синхронизации доступа к словарям 15 | lock = Lock() 16 | 17 | 18 | def clear_unused_info(chat_id) -> None: 19 | """ 20 | Отчищает данные из GoogleSheet, 21 | не затрагивает кэшированные данные 22 | 23 | :param chat_id: id пользователя 24 | """ 25 | if CLIENT_DICT.get(chat_id): 26 | client = CLIENT_DICT[chat_id] 27 | client.lst_currant_date = None 28 | client.dct_currant_time = None 29 | # client.lst_records = None 30 | client.name_service = None 31 | client.name_master = None 32 | client.date_record = None 33 | client.time_record = None 34 | 35 | if CALENDAR_DICT.get(chat_id): 36 | del CALENDAR_DICT[chat_id] 37 | 38 | 39 | def clear_all_dict(chat_id) -> None: 40 | """ 41 | Отчищает все словари по chat_id 42 | 43 | :param chat_id: id пользователя 44 | """ 45 | if CLIENT_DICT.get(chat_id): 46 | del CLIENT_DICT[chat_id] 47 | if CALENDAR_DICT.get(chat_id): 48 | del CALENDAR_DICT[chat_id] 49 | if TIMER_DICT.get(chat_id): 50 | del TIMER_DICT[chat_id] 51 | 52 | 53 | def clear_client_dict(period_clear_minutes=60) -> None: 54 | """ 55 | Отчищает все неактивные элементы словарей 56 | 57 | :param period_clear_minutes: периодичность отчистки в минутах 58 | """ 59 | while True: 60 | sleep(period_clear_minutes * 60) 61 | at_now = datetime.now() 62 | lst_to_del = [] 63 | with lock: 64 | for key, val in TIMER_DICT.items(): 65 | if val + timedelta(minutes=period_clear_minutes) >= at_now: 66 | lst_to_del.append(key) 67 | for chat_id in lst_to_del: 68 | clear_all_dict(chat_id) 69 | 70 | 71 | clear_thread = Thread(target=clear_client_dict) 72 | clear_thread.daemon = True 73 | clear_thread.start() 74 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | 123 | # Spyder project settings 124 | .spyderproject 125 | .spyproject 126 | 127 | # Rope project settings 128 | .ropeproject 129 | 130 | # mkdocs documentation 131 | /site 132 | 133 | # mypy 134 | .mypy_cache/ 135 | .dmypy.json 136 | dmypy.json 137 | 138 | # Pyre type checker 139 | .pyre/ 140 | 141 | # pytype static type analyzer 142 | .pytype/ 143 | 144 | # Cython debug symbols 145 | cython_debug/ 146 | 147 | # PyCharm 148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can 149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 150 | # and can be added to the global gitignore or merged into this file. For a more nuclear 151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 152 | #.idea/ 153 | /beautysaloon.json 154 | /config.py 155 | /tetstik.py 156 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # saloon_bot 💅 2 | 3 | **saloon_bot** - *Telegram bot* для записи в салон красоты с использованием *Google Sheets* 4 | 5 | ![Static Badge](https://img.shields.io/badge/python-3.11-blue) 6 | ![Static Badge](https://img.shields.io/badge/TelegramBotAPI-4.12.0-blue) 7 | ![Static Badge](https://img.shields.io/badge/gspread-5.10.0-blue) 8 | ![Static Badge](https://img.shields.io/badge/pylint_score-9%2C5-green) 9 | 10 |

11 | IMG-1551-1 12 |

13 | 14 | ------ 15 | 16 | ## Описание 17 | Данный проект представляет собой ***телеграмм бота***, который позволяет пользователям записываться в салон красоты. 18 | Бот использует ***Google Sheets*** для хранения информации о клиентах и их записях. 19 | 20 | **Пример таблицы:** https://docs.google.com/spreadsheets/d/1VmucIj0jhJcIDv3tkfpXtlLoDRh4Zhoa8DuCTzOuhuQ/edit?usp=sharing 21 | 22 | 23 | **telebot documentation:** [https://github.com/eternnoir/pyTelegramBotAPI](https://github.com/eternnoir/pyTelegramBotAPI) 24 | 25 | **gspread documentation:** [https://docs.gspread.org/en/v5.7.2/](https://docs.gspread.org/en/v5.7.2/) 26 | 27 | 28 | ## Установка 29 | 30 | ``` 31 | git clone https://github.com/frolovelo/saloon_bot.git 32 | ``` 33 | ## Зависимости 34 | 35 | **Windows** 36 | 37 | ```bash 38 | pip install -r requirements.txt 39 | ``` 40 | 41 | **macOS/Linux:** 42 | 43 | ```bash 44 | pip3 install -r requirements.txt 45 | ``` 46 | 47 | **Активация виртуального окружения (Windows):** 48 | 49 | ```bash 50 | \venv\Scripts\activate 51 | ``` 52 | 53 | **Активация виртуального окружения (macOS/Linux):** 54 | 55 | ```bash 56 | source venv/bin/activate 57 | ``` 58 | 59 | ## Использование 60 | 61 | 1. Создайте ***config.py*** с содержимым: 62 | ```python 63 | TOKEN = "YOUR_BOT_TOKEN" 64 | ``` 65 | 2. Получите **json key** от *Google Sheets* 66 | 67 | Как получить: 68 | 69 | - Создать аккаунт Google. Аккаунт позволяет пользоваться большинством сервисов Google без необходимости регистрироваться в каждом из них. 70 | - Открыть страницу console.developers.google.com и нажать «Создать проект». 71 | - Ввести имя проекта и нажать «Создать». 72 | - Выбрать на своём проекте меню «Настройки». 73 | - Перейти в пункт меню «Сервисные аккаунты», а затем «Создать сервисный аккаунт». 74 | - Ввести название аккаунта и нажать «Создать и продолжить». 75 | - Выбрать роль «Владелец» и нажать «Продолжить». 76 | - Завершить создание сервисного аккаунта. 77 | - Открыть пункт управления ключами. 78 | - Нажать «Добавить ключ». 79 | - Нажать «Сгенерировать новый ключ». 80 | - Выбрать тип JSON и нажать «Создать». 81 | - Скопировать адрес электронной почты (он будет необходим при выдаче прав на доступ сервисного аккаунта к таблице). 82 | - Открыть навигационное меню и перейти на вкладку «API & Сервисы». 83 | - В поле поиска ввести «Google Sheets API» и кликнуть на соответствующий вариант результата поиска. 84 | - Активировать сервис «Google Sheets API». 85 | - Для работы с Google таблицей необходимо предоставить доступ к ней созданному аккаунту. Для этого нужно открыть Google таблицу и нажать «Настройки Доступа». 86 | - Ввести в поле поиска адрес электронной почты сервисного аккаунта (из пункта 14 раздела «Регистрация сервисного аккаунта»). 87 | - Указать права доступа и кликнуть «Отправить». 88 | 89 | Структура ключа: 90 | ```json 91 | { 92 | "type": "service_account", 93 | "project_id": "beautysaloon", 94 | "private_key_id": "fGEFEfeEWR343253235", 95 | "private_key": "-----BEGIN PRIVATE KEY-----\n", 96 | "client_email": "my-account-service@beautysaloon.iam.gserviceaccount.com", 97 | "client_id": "10275785785778592", 98 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 99 | "token_uri": "https://oauth2.googleapis.com/token", 100 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 101 | "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/my-account-service", 102 | "universe_domain": "googleapis.com" 103 | } 104 | ``` 105 | 3. Замените название ключа в [google_sheet.py](google_sheet.py) 106 | ```python 107 | # Название файла json ключа 108 | creds = Credentials.from_service_account_file('YOUR_NAME_KEY.json', scopes=myscope) 109 | client_main = gspread.Client(creds) 110 | ``` 111 | 112 | 4. Для тестового запуска *рекомендуется* скопировать данные из ***примера таблицы:*** https://docs.google.com/spreadsheets/d/1VmucIj0jhJcIDv3tkfpXtlLoDRh4Zhoa8DuCTzOuhuQ/edit?usp=sharing 113 | 114 | 115 | 5. Смените данные на свои в [google_sheet.py](google_sheet.py) 116 | ```python 117 | # Название таблицы 118 | sh = client_main.open('YOUR_TABLE_NAME') 119 | # Страницы таблицы, которые должны игнорироваться во избежание проблем 120 | IGNOR_WORKSHEETS = ['Работники'] 121 | # Страница таблицы, на которой перечислены все действующие работники и услуги 122 | NAME_SHEET_WORKERS = 'Работники' 123 | # Названия основных колонок(очередность важна!) 124 | NAME_COL_SERVICE = 'Услуга' 125 | NAME_COL_MASTER = 'Мастер' 126 | ``` 127 | * ``` sh = client_main.open('YOUR_TABLE_NAME')``` - имя вашей таблицы 128 | * ```IGNOR_WORKSHEETS``` - имена листов, структура которых отличается от листов для записи 129 | * ```NAME_SHEET_WORKERS``` - имя листа со всеми услугами и работниками 130 | * ``` NAME_COL_SERVICE``` и ```NAME_COL_MASTER``` - названия колонок в вашей таблице 131 | 132 | #### Примечание: 133 | 1. Лист ```NAME_SHEET_WORKERS``` требуется для выдачи клиентам списка мастеров и услуг; 134 | 135 |

136 | image 137 |

138 | 139 | 2. Листы для записи должны иметь определенный формат имени: 'дд.мм.гг'; 140 | 141 |

142 | image 143 |

144 | 145 | 3. В листах для записи следует соблюдать лишь первые две колонки: 'Услуга', 'Мастер', 146 | время для записи вы можете ставить на своё усмотрение. 147 | 148 |

149 | 123123123 150 |

151 | 152 | ## Структура проекта 153 | 154 | * [config.py]() - токен бота 155 | * [main.py](main.py) - telegram бот 156 | * [google_sheet.py](google_sheet.py) - работа с Google Sheet 157 | * [clear_dict.py](clear_dict.py) - хранение информации о пользователях и периодичная отчистка 158 | * [keyboards.py](keyboards.py) - клавиатуры и кнопки Telebot 159 | * [telebot_calendar.py](telebot_calendar.py) - клавиатура в виде календаря 160 | * [requirements.txt](requirements.txt) - библиотеки 161 | 162 | ## Вклад и разработка 163 | Если вы обнаружили ошибки или у вас есть предложения по улучшению проекта, пожалуйста, создайте Issue или Pull Request в репозитории проекта. 164 | 165 | ## TO-DO 166 | 167 | - [x] Безопасность потоков 168 | - [x] Дополнительные запросы к *Google Sheets* при возникновении ошибок ```google_sheet.py``` 169 | - [x] Оптимальное использование памяти, отчистка по таймауту ```clear-dict.py``` 170 | - [x] Кэширование данных из *Google Sheets* для экономии кол-ва запросов к api 171 | - [ ] SQLAlchemy/MongoDB для хранения номеров телефона пользователя 172 | - [ ] Удаление дат, которые были свободны, но в процессе бронирования заполнились 173 | - [ ] Асинхронный Telebot + Анимация загрузки 174 | - [ ] Функционал напоминаний о записи 175 | - [ ] Создание вспомогательного бота админа для удаленной настройки бота 176 | - [ ] Отправка уведомлений о новых записях администратору салона на доп. аккаунт telegram 177 | 178 | ## Референсы 179 | 180 | [purgy](https://github.com/purgy/telebot-calendar) - Telebot календарь 181 | -------------------------------------------------------------------------------- /telebot_calendar.py: -------------------------------------------------------------------------------- 1 | """ 2 | Календарь из inline-кнопок 3 | """ 4 | import datetime 5 | import calendar 6 | import typing 7 | 8 | from telebot import TeleBot 9 | from telebot.types import InlineKeyboardButton, InlineKeyboardMarkup, CallbackQuery 10 | 11 | MONTHS = ( 12 | "Январь", 13 | "Февраль", 14 | "Март", 15 | "Апрель", 16 | "Май", 17 | "Июнь", 18 | "Июль", 19 | "Август", 20 | "Сентябрь", 21 | "Октябрь", 22 | "Ноябрь", 23 | "Декабрь", 24 | ) 25 | DAYS = ("Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс") 26 | 27 | 28 | class CallbackData: 29 | """ 30 | Callback data factory 31 | """ 32 | 33 | def __init__(self, prefix, *parts, sep=":"): 34 | if not isinstance(prefix, str): 35 | raise TypeError( 36 | f"Prefix must be instance of str not {type(prefix).__name__}" 37 | ) 38 | if not prefix: 39 | raise ValueError("Prefix can't be empty") 40 | if sep in prefix: 41 | raise ValueError(f"Separator {sep!r} can't be used in prefix") 42 | if not parts: 43 | raise TypeError("Parts were not passed!") 44 | # print(prefix, parts) 45 | self.prefix = prefix 46 | self.sep = sep 47 | 48 | self._part_names = parts 49 | 50 | def new(self, *args, **kwargs) -> str: 51 | """ 52 | Generate callback data 53 | 54 | :param args: 55 | :param kwargs: 56 | :return: 57 | """ 58 | 59 | args = list(args) 60 | 61 | data = [self.prefix] 62 | 63 | for part in self._part_names: 64 | value = kwargs.pop(part, None) 65 | if value is None: 66 | if args: 67 | value = args.pop(0) 68 | else: 69 | raise ValueError(f"Value for {part!r} was not passed!") 70 | 71 | if value is not None and not isinstance(value, str): 72 | value = str(value) 73 | 74 | if not value: 75 | raise ValueError(f"Value for part {part!r} can't be empty!'") 76 | if self.sep in value: 77 | raise ValueError( 78 | f"Symbol {self.sep!r} is defined as the separator and can't be used in parts' values" 79 | ) 80 | 81 | data.append(value) 82 | 83 | if args or kwargs: 84 | raise TypeError("Too many arguments were passed!") 85 | 86 | callback_data = self.sep.join(data) 87 | if len(callback_data) > 64: 88 | raise ValueError("Resulted callback data is too long!") 89 | 90 | return callback_data 91 | 92 | def parse(self, callback_data: str) -> typing.Dict[str, str]: 93 | """ 94 | Parse data from the callback data 95 | 96 | :param callback_data: 97 | :return: 98 | """ 99 | 100 | prefix, *parts = callback_data.split(self.sep) 101 | 102 | if prefix != self.prefix: 103 | raise ValueError( 104 | "Passed callback data can't be parsed with that prefix.") 105 | elif len(parts) != len(self._part_names): 106 | raise ValueError("Invalid parts count!") 107 | 108 | result = {"@": prefix} 109 | result.update(zip(self._part_names, parts)) 110 | 111 | return result 112 | 113 | def filter(self, **config): 114 | """ 115 | Generate filter 116 | 117 | :param config: 118 | :return: 119 | """ 120 | 121 | # print(config, self._part_names) 122 | for key in config.keys(): 123 | if key not in self._part_names: 124 | return False 125 | 126 | return True 127 | 128 | 129 | def create_calendar(lst_current_date: list[datetime.date], name: str = "calendar", year: int = None, month: int = None, 130 | ) -> InlineKeyboardMarkup: 131 | """ 132 | Create a built-in inline keyboard with calendar 133 | 134 | :param lst_current_date: Список доступных дат для записи в формате datetime.date 135 | :param name: Имя календаря 136 | :param year: Год используемый календарём, если не используете текущий год. 137 | :param month: Месяц используемый календарём, если не используете текущий месяц. 138 | :return: Возвращает объект InlineKeyboardMarkup с календарём. 139 | """ 140 | 141 | now_day = datetime.datetime.now() 142 | 143 | if year is None: 144 | year = now_day.year 145 | if month is None: 146 | month = now_day.month 147 | 148 | calendar_callback = CallbackData(name, "action", "year", "month", "day") 149 | data_ignore = calendar_callback.new("IGNORE", year, month, "!") 150 | data_months = calendar_callback.new("MONTHS", year, month, "!") 151 | 152 | keyboard = InlineKeyboardMarkup(row_width=7) 153 | 154 | keyboard.add( 155 | InlineKeyboardButton( 156 | MONTHS[month - 1] + " " + str(year), callback_data=data_months 157 | ) 158 | ) 159 | 160 | keyboard.add( 161 | *[InlineKeyboardButton(day, callback_data=data_ignore) for day in DAYS] 162 | ) 163 | 164 | for week in calendar.monthcalendar(year, month): 165 | row = list() 166 | for day in week: 167 | if day == 0: 168 | row.append(InlineKeyboardButton( 169 | " ", callback_data=data_ignore)) 170 | else: 171 | if datetime.date(year, month, day) in lst_current_date: 172 | row.append( 173 | InlineKeyboardButton( 174 | str(day) + '✅', 175 | callback_data=calendar_callback.new( 176 | "DAY", year, month, day), 177 | ) 178 | ) 179 | else: 180 | row.append( 181 | InlineKeyboardButton( 182 | str(day), 183 | callback_data=calendar_callback.new( 184 | "DAY_EMPTY", year, month, day), 185 | ) 186 | ) 187 | keyboard.add(*row) 188 | 189 | keyboard.add( 190 | InlineKeyboardButton( 191 | "<", callback_data=calendar_callback.new("PREVIOUS-MONTH", year, month, "!") 192 | ), 193 | InlineKeyboardButton( 194 | "Назад", callback_data=calendar_callback.new("RETURN", year, month, "!") 195 | ), 196 | InlineKeyboardButton( 197 | "Меню", callback_data=calendar_callback.new("MENU", year, month, "!") 198 | ), 199 | InlineKeyboardButton( 200 | ">", callback_data=calendar_callback.new("NEXT-MONTH", year, month, "!") 201 | ), 202 | ) 203 | 204 | return keyboard 205 | 206 | 207 | def create_months_calendar( 208 | name: str = "calendar", year: int = None 209 | ) -> InlineKeyboardMarkup: 210 | """ 211 | Создаёт календарь из месяцев 212 | 213 | param name: 214 | param year: 215 | return: 216 | """ 217 | 218 | if year is None: 219 | year = datetime.datetime.now().year 220 | 221 | calendar_callback = CallbackData(name, "action", "year", "month", "day") 222 | 223 | keyboard = InlineKeyboardMarkup() 224 | 225 | for i, month in enumerate(zip(MONTHS[0::2], MONTHS[1::2])): 226 | keyboard.add( 227 | InlineKeyboardButton( 228 | month[0], callback_data=calendar_callback.new( 229 | "MONTH", year, 2 * i + 1, "!") 230 | ), 231 | InlineKeyboardButton( 232 | month[1], 233 | callback_data=calendar_callback.new( 234 | "MONTH", year, 2 * i + 2, "!"), 235 | ), 236 | ) 237 | return keyboard 238 | 239 | 240 | def calendar_query_handler( 241 | bot: TeleBot, 242 | call: CallbackQuery, 243 | name: str, 244 | action: str, 245 | year: int, 246 | month: int, 247 | day: int, 248 | lst_currant_date: list 249 | ) -> None or datetime.datetime: 250 | """ 251 | The method creates a new calendar if the forward or backward button is pressed 252 | This method should be called inside CallbackQueryHandler. 253 | 254 | 255 | :param bot: The object of the bot CallbackQueryHandler 256 | :param call: CallbackQueryHandler data 257 | :param day: 258 | :param month: 259 | :param year: 260 | :param action: 261 | :param name: 262 | :param lst_currant_date: Список доступных дат для записи в формате date 263 | :return: Returns a tuple 264 | """ 265 | 266 | current = datetime.datetime(int(year), int(month), 1) 267 | if action == "IGNORE": 268 | bot.answer_callback_query(callback_query_id=call.id, text='Тут ничего нет') 269 | return False, None 270 | elif action == "DAY_EMPTY": 271 | bot.answer_callback_query(callback_query_id=call.id, text='Свободного времени нет') 272 | return False, None 273 | elif action == "DAY": 274 | return datetime.datetime(int(year), int(month), int(day)) 275 | elif action == "PREVIOUS-MONTH": 276 | preview_month = current - datetime.timedelta(days=1) 277 | bot.edit_message_text( 278 | text=call.message.text, 279 | chat_id=call.message.chat.id, 280 | message_id=call.message.message_id, 281 | reply_markup=create_calendar( 282 | name=name, year=int(preview_month.year), month=int(preview_month.month), 283 | lst_current_date=lst_currant_date 284 | ), 285 | ) 286 | return None 287 | elif action == "NEXT-MONTH": 288 | next_month = current + datetime.timedelta(days=31) 289 | bot.edit_message_text( 290 | text=call.message.text, 291 | chat_id=call.message.chat.id, 292 | message_id=call.message.message_id, 293 | reply_markup=create_calendar( 294 | name=name, year=int(next_month.year), month=int(next_month.month), 295 | lst_current_date=lst_currant_date 296 | ), 297 | ) 298 | return None 299 | elif action == "MONTHS": 300 | bot.edit_message_text( 301 | text=call.message.text, 302 | chat_id=call.message.chat.id, 303 | message_id=call.message.message_id, 304 | reply_markup=create_months_calendar(name=name, year=current.year), 305 | ) 306 | return None 307 | elif action == "MONTH": 308 | bot.edit_message_text( 309 | text=call.message.text, 310 | chat_id=call.message.chat.id, 311 | message_id=call.message.message_id, 312 | reply_markup=create_calendar( 313 | name=name, year=int(year), month=int(month), 314 | lst_current_date=lst_currant_date), 315 | ) 316 | return None 317 | elif action == "MENU": 318 | # bot.delete_message( 319 | # chat_id=call.message.chat.id, message_id=call.message.message_id 320 | # ) 321 | return "MENU", None 322 | elif action == "RETURN": 323 | return "RETURN", None 324 | else: 325 | bot.answer_callback_query(callback_query_id=call.id, text="ERROR!") 326 | # bot.delete_message( 327 | # chat_id=call.message.chat.id, message_id=call.message.message_id 328 | # ) 329 | return None 330 | -------------------------------------------------------------------------------- /google_sheet.py: -------------------------------------------------------------------------------- 1 | """ 2 | Взаимодействие с Google Sheets 3 | """ 4 | from datetime import datetime, timedelta 5 | from time import time 6 | from threading import Lock 7 | import json 8 | from concurrent.futures import ThreadPoolExecutor 9 | from google.oauth2.service_account import Credentials 10 | from retrying import retry 11 | from cachetools import TTLCache 12 | import gspread 13 | from pytz import timezone 14 | 15 | myscope = ["https://www.googleapis.com/auth/spreadsheets", 16 | "https://www.googleapis.com/auth/drive"] 17 | 18 | # Временная зона 19 | tz = timezone("Europe/Moscow") 20 | # Название файла json ключа 21 | creds = Credentials.from_service_account_file('beautysaloon.json', scopes=myscope) 22 | client_main = gspread.Client(creds) 23 | # Название таблицы 24 | sh = client_main.open('SaloonSheet') 25 | # Страницы таблицы, которые должны игнорироваться во избежание проблем 26 | IGNOR_WORKSHEETS = ['Работники'] 27 | # Страница таблицы, на которой перечислены все действующие работники и услуги 28 | NAME_SHEET_WORKERS = 'Работники' 29 | # Названия основных колонок(очередность важна!) 30 | NAME_COL_SERVICE = 'Услуга' 31 | NAME_COL_MASTER = 'Мастер' 32 | 33 | # Кэш листов с TTL (временем жизни) в 12 часов 34 | CACHE_WORKSHEETS = TTLCache(maxsize=2, ttl=12 * 60 * 60) 35 | # Кэш доступных дат для услуги-мастера с TTL (временем жизни) в 15 минут 36 | CACHE_DAYS = TTLCache(maxsize=6, ttl=15 * 60) 37 | # Lock для синхронизации доступа к словарям 38 | lock = Lock() 39 | 40 | 41 | # Функция для сериализации словаря в JSON-строку 42 | def serialize_dict(dct: dict) -> str: 43 | """Сериализатор json""" 44 | return json.dumps(dct) 45 | 46 | 47 | # Функция для десериализации JSON-строки в словарь 48 | def deserialize_dict(json_str: str) -> dict: 49 | """Десериализатор json""" 50 | return json.loads(json_str) 51 | 52 | 53 | def get_cache_days(service_name: str, master_name: str) -> list | None: 54 | """ 55 | Запрашивает свободные даты из кэша cashe_days 56 | 57 | :param service_name: Название услуги 58 | :param master_name: Имя мастера 59 | """ 60 | if service_name in CACHE_DAYS: 61 | cached_value = CACHE_DAYS[service_name] 62 | cached_dict = deserialize_dict(cached_value) 63 | if master_name in cached_dict: 64 | return cached_dict[master_name] 65 | return None 66 | 67 | 68 | def update_cache_days(service_name: str, master_name: str, available_dates: list) -> None: 69 | """ 70 | Обновляет свободные даты для кэша cache_days 71 | 72 | :param service_name: Название услуги 73 | :param master_name: Имя мастера 74 | :param available_dates: Доступные даты 75 | """ 76 | if master_name is None: 77 | master_name = 'null' 78 | 79 | if service_name in CACHE_DAYS: 80 | cached_value = CACHE_DAYS[service_name] 81 | cached_dict = deserialize_dict(cached_value) 82 | if master_name not in cached_dict: 83 | cached_dict[master_name] = available_dates 84 | cache_value = serialize_dict(cached_dict) 85 | CACHE_DAYS[service_name] = cache_value 86 | else: 87 | cache_value = serialize_dict({master_name: available_dates}) 88 | CACHE_DAYS[service_name] = cache_value 89 | 90 | 91 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 92 | def get_sheet_names() -> list: 93 | """ 94 | Запрашивает все имена листов таблицы 95 | """ 96 | # Проверяем, есть ли результат в кэше 97 | if 'worksheets' in CACHE_WORKSHEETS: 98 | return CACHE_WORKSHEETS['worksheets'] 99 | with lock: 100 | worksheets = sh.worksheets() 101 | 102 | # Кэшируем результат 103 | CACHE_WORKSHEETS['worksheets'] = worksheets 104 | return worksheets 105 | 106 | 107 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 108 | def get_cache_services() -> dict: 109 | """ 110 | Запрашивает все услуги 111 | """ 112 | if 'services' in CACHE_WORKSHEETS: 113 | return CACHE_WORKSHEETS['services'] 114 | dct = {} 115 | with lock: 116 | ws = sh.worksheet(NAME_SHEET_WORKERS) 117 | for i in ws.get_all_records(): 118 | dct[i[NAME_COL_SERVICE].strip()] = dct.get(i[NAME_COL_SERVICE].strip(), []) 119 | dct[i[NAME_COL_SERVICE].strip()].append(i[NAME_COL_MASTER].strip()) 120 | 121 | CACHE_WORKSHEETS['services'] = dct 122 | return dct 123 | 124 | 125 | def time_score(func): 126 | """Декоратор для трекинга времени выполнения функции""" 127 | 128 | def wrapper(*args, **kwargs): 129 | start = time() 130 | res = func(*args, **kwargs) 131 | print(f"---{func.__name__} = %s seconds ---" % round(time() - start, 2)) 132 | return res 133 | 134 | return wrapper 135 | 136 | 137 | class GoogleSheets: 138 | """Взаимодействие с GoogleSheet""" 139 | 140 | def __init__(self, client_id: str): 141 | # уникальный идентификатор для создания объекта 142 | self.client_id = client_id 143 | # доступные даты 144 | self.lst_currant_date = None 145 | # доступное время 146 | self.dct_currant_time = None 147 | # записи клиента 148 | self.lst_records = None 149 | 150 | self.name_service = None 151 | self.name_master = None 152 | self.date_record = None 153 | self.time_record = None 154 | 155 | def __str__(self): 156 | return f'Инфо о клиенте:\n' \ 157 | f'{self.client_id=}\n' \ 158 | f'{self.name_service=}\n' \ 159 | f'{self.name_master=}\n' \ 160 | f'{self.date_record=}\n' \ 161 | f'{self.time_record=}' 162 | 163 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=9000) 164 | def get_all_days(self) -> list: 165 | """Все доступные дни для записи на определенную услугу""" 166 | 167 | check = get_cache_days(self.name_service, self.name_master) 168 | if check: 169 | return check 170 | 171 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 172 | def actual_date(sheet_obj, count_days=7) -> bool: 173 | """ 174 | Проверяет по объекту листа актуальные даты для записи на ближайшие count_days дней, 175 | а также наличие свободного времени. 176 | 177 | :param sheet_obj: Объект листа (объект gspread) 178 | :param count_days: Количество ближайших дней для поиска (int) 179 | 180 | :return: True - есть доступные свободные слоты; False - все слоты заняты или нет актуальных дат 181 | """ 182 | if sheet_obj.title in IGNOR_WORKSHEETS: 183 | return False 184 | try: 185 | date_sheet = datetime.strptime(sheet_obj.title.strip(), '%d.%m.%y').date() 186 | except Exception as ex: 187 | print(ex) 188 | print(sheet_obj.title, '- Добавьте лист в IGNOR_WORKSHEETS') 189 | return False 190 | date_today = datetime.now(tz=tz) 191 | if not date_today.date() <= date_sheet <= (datetime.now(tz=tz).date() + timedelta(days=count_days)): 192 | return False 193 | with lock: 194 | val = sheet_obj.get_all_records() 195 | for dct in val: 196 | 197 | if date_today.date() == date_sheet: 198 | if (self.name_master is not None and 199 | dct[NAME_COL_MASTER].strip() == self.name_master and 200 | dct[NAME_COL_SERVICE].strip() == self.name_service) or \ 201 | (self.name_master is None and 202 | dct[NAME_COL_SERVICE].strip() == self.name_service): 203 | for k, v in dct.items(): 204 | if str(v).strip() == '' and date_today.time() < datetime.strptime(k, '%H:%M').time(): 205 | return sheet_obj.title 206 | continue 207 | 208 | if (self.name_master is not None and dct[NAME_COL_MASTER].strip() == self.name_master and 209 | dct[NAME_COL_SERVICE].strip() == self.name_service) \ 210 | or (self.name_master is None and dct[NAME_COL_SERVICE].strip() == self.name_service): 211 | for k, v in dct.items(): 212 | if str(v).strip() == '': 213 | return sheet_obj.title 214 | return False 215 | 216 | worksheet_all = get_sheet_names() 217 | 218 | with ThreadPoolExecutor(2) as executor: 219 | res = executor.map(actual_date, worksheet_all) 220 | res = list(filter(lambda x: type(x) is str, res)) 221 | 222 | # Кэшируем результат 223 | update_cache_days(self.name_service, self.name_master, res) 224 | return res 225 | 226 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 227 | def get_free_time(self) -> list: 228 | """Функция выгружает ВСЕ СВОБОДНОЕ ВРЕМЯ для определенной ДАТЫ""" 229 | 230 | try: 231 | with lock: 232 | all_val = sh.worksheet(self.date_record).get_all_records() 233 | except gspread.exceptions.WorksheetNotFound as not_found: 234 | print(not_found, self.date_record, '- Дата занята/не найдена') 235 | return [] 236 | 237 | if self.date_record == datetime.now(tz=tz).strftime('%d.%m.%y'): 238 | lst = [k.strip() for i in all_val 239 | if (self.name_master is None and i[NAME_COL_SERVICE].strip() == self.name_service) or 240 | (self.name_master is not None and i[NAME_COL_SERVICE].strip() == self.name_service and 241 | i[NAME_COL_MASTER].strip() == self.name_master) 242 | for k, v in i.items() if str(v).strip() == '' and 243 | datetime.now(tz=tz).time() < datetime.strptime(k, '%H:%M').time()] 244 | else: 245 | lst = [k.strip() for i in all_val 246 | if (self.name_master is None and i[NAME_COL_SERVICE].strip() == self.name_service) or 247 | (self.name_master is not None and i[NAME_COL_SERVICE].strip() == self.name_service and 248 | i[NAME_COL_MASTER].strip() == self.name_master) 249 | for k, v in i.items() if str(v).strip() == ''] 250 | 251 | if len(lst) > 0: 252 | lst = sorted(list(set(lst))) 253 | return lst 254 | 255 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 256 | def set_time(self, client_record='', search_criteria='') -> bool: 257 | """ 258 | Производит в таблицу запись/отмену клиента 259 | 260 | :param client_record: Строка с данными клиента для записи (по умолчанию пустая строка) 261 | :param search_criteria: Критерий поиска на листе - "пустая" или "заполненная" (по умолчанию пустая строка) 262 | 263 | :return: True, если операция прошла успешно; False, если произошла ошибка при выполнении операции 264 | """ 265 | try: 266 | with lock: 267 | all_val = sh.worksheet(self.date_record).get_all_records() 268 | except gspread.exceptions.WorksheetNotFound as not_found: 269 | print(not_found, self.date_record, '- Дата занята/не найдена') 270 | return False 271 | 272 | row_num = 1 273 | for i in all_val: 274 | row_num += 1 275 | col_num = 0 276 | if (self.name_master is None and i[NAME_COL_SERVICE].strip() == self.name_service) or \ 277 | (self.name_master is not None and i[NAME_COL_SERVICE].strip() == self.name_service and 278 | i[NAME_COL_MASTER].strip() == self.name_master): 279 | for key_time, val_use in i.items(): 280 | col_num += 1 281 | if key_time.strip() == self.time_record and val_use.strip() == search_criteria: 282 | if self.name_master is None: 283 | self.name_master = i[NAME_COL_MASTER].strip() 284 | sh.worksheet(self.date_record).update_cell(row_num, col_num, f'{client_record}') 285 | if (self.lst_records and search_criteria == '') or (self.lst_records and client_record == ''): 286 | record = [self.date_record, self.time_record, self.name_service, self.name_master] 287 | if search_criteria == '': 288 | self.lst_records.append(record) 289 | else: 290 | self.lst_records.remove(record) 291 | return True 292 | return False 293 | 294 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 295 | def get_record(self, client_record: str, count_days=7) -> list: 296 | """ 297 | Находит все записи клиента на ближайшие дней 298 | 299 | :param client_record: строка записи клиента. 300 | :param count_days: кол-во ближайших дней для поиска. 301 | 302 | :return: Список - формата: [Дата, Время, Название услуги, Имя мастера] 303 | """ 304 | if self.lst_records: 305 | return self.lst_records 306 | 307 | @retry(wait_exponential_multiplier=3000, wait_exponential_max=3000) 308 | def check_record(sheet_obj) -> None: 309 | """Поиск брони клиента""" 310 | if sheet_obj.title in IGNOR_WORKSHEETS: 311 | return None 312 | try: 313 | date_sheet = datetime.strptime(sheet_obj.title, '%d.%m.%y') 314 | except Exception as ex: 315 | print(ex) 316 | print(sheet_obj.title, '- Добавьте лист в IGNOR_WORKSHEETS') 317 | return None 318 | date_today = datetime.now(tz=tz) 319 | if date_today.date() == date_sheet.date(): 320 | with lock: 321 | all_val = sheet_obj.get_all_records() 322 | for dct in all_val: 323 | for k, v in dct.items(): 324 | if v == client_record: 325 | try: 326 | record_time = datetime.strptime(k, '%H:%M').time() 327 | if date_today.time() < record_time: 328 | print("Запись найдена:") 329 | print([sheet_obj.title.strip(), k.strip(), dct[NAME_COL_SERVICE].strip(), 330 | dct[NAME_COL_MASTER].strip()]) 331 | lst_records.append( 332 | [sheet_obj.title.strip(), k.strip(), dct[NAME_COL_SERVICE].strip(), 333 | dct[NAME_COL_MASTER].strip()] 334 | ) 335 | else: 336 | # print("Пропущена (время уже прошло)") 337 | pass 338 | 339 | except Exception as e: 340 | print(f"Ошибка при разборе времени: {e}, значение ключа: {k}") 341 | 342 | elif date_today.date() < date_sheet.date() <= (date_today + timedelta(days=count_days)).date(): 343 | with lock: 344 | all_val = sheet_obj.get_all_records() 345 | lst_records.extend( 346 | [sheet_obj.title.strip(), k.strip(), dct[NAME_COL_SERVICE].strip(), dct[NAME_COL_MASTER].strip()] 347 | for dct in all_val 348 | for k, v in dct.items() 349 | if v == client_record 350 | ) 351 | 352 | lst_records = [] 353 | with ThreadPoolExecutor(2) as executor: 354 | executor.map(check_record, sh.worksheets()) 355 | self.lst_records = lst_records 356 | return lst_records 357 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Взаимодействие с Telegram 3 | """ 4 | from datetime import datetime 5 | from telebot import types, TeleBot 6 | from telebot.types import CallbackQuery, ReplyKeyboardRemove, \ 7 | ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton 8 | from config import TOKEN 9 | import telebot_calendar 10 | from google_sheet import GoogleSheets, get_cache_services 11 | from keyboards import create_markup_menu, button_to_menu 12 | import clear_dict 13 | 14 | bot = TeleBot(TOKEN) 15 | 16 | CLIENT_PHONE = {467168798: '+79522600066', 288041146: '+79215528067'} # sql сделать 17 | 18 | 19 | def get_client_id(client_id, client_username) -> str: 20 | """Создаёт строку записи пользователя 21 | 22 | :param client_id: id чата/пользователя 23 | :param client_username: username пользователя 24 | :return: 'id: id @username tel: phone'""" 25 | id_client = f"id: {str(client_id)}\n@{str(client_username)}\n" 26 | if CLIENT_PHONE.get(client_id, None) is not None: 27 | if CLIENT_PHONE[client_id] != '': 28 | id_client += 'tel: ' + CLIENT_PHONE[client_id] 29 | else: 30 | id_client += 'tel: None' 31 | return id_client 32 | 33 | 34 | def create_client(chat_id) -> GoogleSheets: 35 | """ 36 | Создаёт объект GoogleSheet по chat_id 37 | 38 | :chat_id: id чата/клиента 39 | """ 40 | if clear_dict.CLIENT_DICT.get(chat_id): 41 | return clear_dict.CLIENT_DICT[chat_id] 42 | client = GoogleSheets(chat_id) 43 | clear_dict.CLIENT_DICT[chat_id] = client 44 | clear_dict.TIMER_DICT[chat_id] = datetime.now() 45 | return client 46 | 47 | 48 | @bot.message_handler(commands=['start']) 49 | def check_phone_number(message): 50 | """Запрашивает номер телефона у пользователя единожды""" 51 | 52 | if CLIENT_PHONE.get(message.chat.id, None) is None: 53 | markup = ReplyKeyboardMarkup(one_time_keyboard=True, resize_keyboard=True) 54 | button_phone = types.KeyboardButton(text="Отправить телефон 📞", 55 | request_contact=True) 56 | markup.add(button_phone) 57 | bot.send_message(message.chat.id, 'Для записи на услуги требуется номер телефона.', 58 | reply_markup=markup) 59 | 60 | @bot.message_handler(content_types=['contact']) 61 | def contact(message_contact): 62 | """Получает объект -> вызывает функцию стартового меню""" 63 | if message_contact.contact is not None: 64 | CLIENT_PHONE[message_contact.chat.id] = message_contact.contact.phone_number 65 | bot.send_message(message_contact.chat.id, 66 | text='Спасибо за доверие!', 67 | reply_markup=ReplyKeyboardRemove()) 68 | menu(message_contact) 69 | else: 70 | menu(message) 71 | 72 | 73 | @bot.message_handler(content_types=['text']) 74 | def any_word_before_number(message_any): 75 | """Обработчик любых текстовых сообщений""" 76 | bot.send_message(message_any.chat.id, 77 | text='Пользоваться ботом возможно только при наличии номера телефона!\n' 78 | 'Взаимодействие с ботом происходит кнопками.') 79 | 80 | 81 | def menu(message): 82 | """Главное меню""" 83 | clear_dict.clear_unused_info(message.chat.id) 84 | bot.send_message(message.chat.id, "Выберите пункт меню:", 85 | reply_markup=create_markup_menu()) 86 | 87 | 88 | @bot.callback_query_handler(lambda call: call.data == 'CANCEL_RECORD') 89 | def cancel_record(call): 90 | """ 91 | InlineKeyboardMarkup - Выбор записи для отмены 92 | """ 93 | client = create_client(call.message.chat.id) 94 | client_id = get_client_id(call.message.chat.id, call.from_user.username) 95 | records = client.get_record(client_id) 96 | if len(records) != 0: 97 | markup = InlineKeyboardMarkup(row_width=1) 98 | markup.add( 99 | *[InlineKeyboardButton(text=' - '.join(x[:3]), 100 | callback_data=f'CANCEL {ind}' 101 | ) for ind, x in enumerate(records)]) 102 | markup.add(*button_to_menu(return_callback=None, 103 | menu_text='В главное меню')) 104 | bot.edit_message_text(chat_id=call.message.chat.id, 105 | message_id=call.message.message_id, 106 | text='Какую запись вы хотите отменить?🙈', 107 | reply_markup=markup 108 | ) 109 | else: 110 | bot.edit_message_text(chat_id=call.message.chat.id, 111 | message_id=call.message.message_id, 112 | text='Отменять пока нечего 🤷' 113 | ) 114 | check_phone_number(call.message) 115 | 116 | 117 | @bot.callback_query_handler(lambda call: call.data.startswith('CANCEL')) 118 | def approve_cancel(call): 119 | """ 120 | Обработка inline callback запросов 121 | Подтверждение отмены записи 122 | """ 123 | markup = InlineKeyboardMarkup(row_width=2) 124 | markup.add(*[InlineKeyboardButton(text='Подтверждаю', 125 | callback_data='APPROVE' + call.data), 126 | InlineKeyboardButton(text='В главное меню', 127 | callback_data='MENU')]) 128 | bot.edit_message_text(chat_id=call.message.chat.id, 129 | message_id=call.message.message_id, 130 | text='Точно отменить?', 131 | reply_markup=markup) 132 | 133 | 134 | @bot.callback_query_handler(lambda call: call.data.startswith('APPROVE')) 135 | def set_cancel(call): 136 | """ 137 | Обработка inline callback запросов 138 | Отмена записи 139 | """ 140 | client = clear_dict.CLIENT_DICT.get(call.from_user.id) 141 | if client: 142 | client_info = client.lst_records[int(call.data.split()[1])] 143 | client.date_record, client.time_record, client.name_service, client.name_master = client_info 144 | client_id = get_client_id(call.message.chat.id, call.from_user.username) 145 | if client.set_time('', client_id): 146 | bot.edit_message_text(chat_id=call.message.chat.id, 147 | message_id=call.message.message_id, 148 | text='Запись отменена!') 149 | else: 150 | bot.edit_message_text(chat_id=call.message.chat.id, 151 | message_id=call.message.message_id, 152 | text='Не смог отменить запись.') 153 | check_phone_number(call.message) 154 | else: 155 | go_to_menu(call) 156 | 157 | 158 | @bot.callback_query_handler(lambda call: call.data == 'MY_RECORD') 159 | def show_record(call): 160 | """Показывает все записи клиента""" 161 | client = create_client(call.message.chat.id) 162 | 163 | client_id = get_client_id(call.message.chat.id, call.from_user.username) 164 | records = client.get_record(client_id) 165 | rec = '' 166 | if len(records) != 0: 167 | rec += 'Ближайшие записи:\n\n' 168 | for i in sorted(records, key=lambda x: (x[0], x[1], x[2])): 169 | rec += '🪷' + ' - '.join(i) + '\n' 170 | else: 171 | rec = 'Актуальных записей не найдено 🔍' 172 | bot.edit_message_text(chat_id=call.message.chat.id, 173 | message_id=call.message.message_id, 174 | text=rec 175 | ) 176 | check_phone_number(call.message) 177 | 178 | 179 | @bot.callback_query_handler(lambda call: call.data == 'RECORD') 180 | def choice_service(call): 181 | """ 182 | InlineKeyboardMarkup 183 | Выбор услуги для записи 184 | """ 185 | create_client(call.message.chat.id) 186 | 187 | all_serv = get_cache_services() 188 | markup = InlineKeyboardMarkup(row_width=3) 189 | markup.add(*[InlineKeyboardButton(text=x, 190 | callback_data='SERVICE' + x 191 | ) for x in all_serv.keys()]) 192 | markup.add(*button_to_menu(None)) 193 | bot.edit_message_text(chat_id=call.message.chat.id, 194 | message_id=call.message.message_id, 195 | text="Выбери услугу:", 196 | reply_markup=markup) 197 | 198 | 199 | @bot.callback_query_handler(func=lambda call: call.data.startswith('SERVICE')) 200 | def choice_master(call): 201 | """ 202 | Обработка inline callback запросов 203 | Выбор мастера 204 | """ 205 | client = clear_dict.CLIENT_DICT.get(call.from_user.id) 206 | if client: 207 | client.name_service = call.data[len('SERVICE'):] 208 | dct = get_cache_services() 209 | markup = InlineKeyboardMarkup(row_width=2) 210 | markup.add(*[InlineKeyboardButton(text=x, 211 | callback_data='MASTER' + x 212 | ) for x in dct[client.name_service]]) 213 | markup.add(InlineKeyboardButton(text='Любой мастер', 214 | callback_data='MASTER' + 'ЛЮБОЙ')) 215 | markup.add(*button_to_menu('RECORD')) 216 | bot.edit_message_text(chat_id=call.message.chat.id, 217 | message_id=call.message.message_id, 218 | text="Выбери Мастера:", 219 | reply_markup=markup) 220 | else: 221 | go_to_menu(call) 222 | 223 | 224 | @bot.callback_query_handler(func=lambda call: call.data.startswith('MASTER')) 225 | def choice_date(call): 226 | """ 227 | Обработка inline callback запросов 228 | Выбор даты 229 | """ 230 | client = clear_dict.CLIENT_DICT.get(call.from_user.id) 231 | if client: 232 | if call.data[len('MASTER'):] != 'ЛЮБОЙ': 233 | client.name_master = call.data[len('MASTER'):] 234 | else: 235 | client.name_master = None 236 | lst = client.get_all_days() 237 | lst = list(map(lambda x: datetime.strptime(x, '%d.%m.%y').date(), lst)) 238 | if len(lst) == 0: 239 | service = client.name_service if client.name_service else 'ЛЮБОЙ' 240 | markup = InlineKeyboardMarkup(row_width=2) 241 | markup.add(*button_to_menu('SERVICE' + service)) 242 | bot.edit_message_text(chat_id=call.message.chat.id, 243 | message_id=call.message.message_id, 244 | text="Для выбранного мастера нет доступных дат!\n" 245 | "Попробуй другого мастера😉", 246 | reply_markup=markup) 247 | else: 248 | client.lst_currant_date = lst 249 | clear_dict.CALENDAR_DICT[call.message.chat.id] = str(call.message.chat.id) 250 | bot.edit_message_text(chat_id=call.from_user.id, 251 | message_id=call.message.message_id, 252 | text='Выбери доступную дату:\n ✅ - есть свободное время', 253 | reply_markup=telebot_calendar.create_calendar( 254 | name='CALENDAR' + clear_dict.CALENDAR_DICT[call.message.chat.id], 255 | lst_current_date=lst) 256 | ) 257 | else: 258 | go_to_menu(call) 259 | 260 | 261 | @bot.callback_query_handler(func=lambda call: call.data.startswith('CALENDAR')) 262 | def choice_time(call: CallbackQuery): 263 | """ 264 | Обработка inline callback запросов 265 | Выбор времени 266 | """ 267 | client = clear_dict.CLIENT_DICT.get(call.from_user.id) 268 | if client: 269 | lst = client.lst_currant_date 270 | # At this point, we are sure that this calendar is ours. So we cut the line by the separator of our calendar 271 | name, action, year, month, day = call.data.split(':') 272 | # Processing the calendar. Get either the date or None if the buttons are of a different type 273 | telebot_calendar.calendar_query_handler( 274 | bot=bot, call=call, name=name, action=action, year=year, month=month, day=day, 275 | lst_currant_date=lst 276 | ) 277 | 278 | if action == "DAY": 279 | client.date_record = datetime(int(year), int(month), int(day)).strftime('%d.%m.%y') 280 | lst_times = client.get_free_time() 281 | client.dct_currant_time = lst_times 282 | 283 | markup = InlineKeyboardMarkup(row_width=3) 284 | markup.add(*[InlineKeyboardButton(text=x, 285 | callback_data='TIME' + x 286 | ) for x in lst_times]) 287 | master = 'MASTER' + (client.name_master if client.name_master else 'ЛЮБОЙ') 288 | markup.add(*button_to_menu(master)) 289 | text = "Выберите время:" if len(lst_times) != 0 else "Для выбранного даты нет доступного времени!\n" \ 290 | "Попробуй другую дату😉" 291 | bot.delete_message(chat_id=call.message.chat.id, 292 | message_id=call.message.message_id) 293 | bot.send_message( 294 | chat_id=call.from_user.id, 295 | text=text, 296 | reply_markup=markup 297 | ) 298 | 299 | elif action == "MENU": 300 | go_to_menu(call) 301 | elif action == "RETURN": 302 | call.data = 'SERVICE' + client.name_service 303 | choice_master(call) 304 | else: 305 | go_to_menu(call) 306 | 307 | 308 | @bot.callback_query_handler(lambda call: call.data.startswith('TIME')) 309 | def approve_record(call): 310 | """ 311 | Обработка inline callback запросов 312 | Подтверждение отмены записи 313 | """ 314 | client = clear_dict.CLIENT_DICT.get(call.from_user.id) 315 | 316 | if client: 317 | client.time_record = call.data[len('TIME'):] 318 | id_calendar = clear_dict.CALENDAR_DICT[call.from_user.id] 319 | date_string = client.date_record 320 | date_object = datetime.strptime(date_string, '%d.%m.%y') 321 | formatted_date = date_object.strftime('%Y:') + str(date_object.month) + ':' + str(date_object.day) 322 | name_calendar = 'CALENDAR' + id_calendar + ':DAY:' + formatted_date 323 | 324 | markup = InlineKeyboardMarkup(row_width=2) 325 | markup.add(InlineKeyboardButton(text='Подтверждаю', 326 | callback_data='APP_REC')) 327 | markup.add(*button_to_menu(name_calendar)) 328 | bot.edit_message_text(chat_id=call.message.chat.id, 329 | message_id=call.message.message_id, 330 | text=f'Проверьте данные записи:\n\n' 331 | f'🛎️ Услуга: {client.name_service}\n' 332 | f'👤 Мастер: {client.name_master if client.name_master else "Любой"}\n' 333 | f'📅 Дата: {client.date_record}\n' 334 | f'🕓 Время: {client.time_record}', 335 | reply_markup=markup) 336 | else: 337 | go_to_menu(call) 338 | 339 | 340 | @bot.callback_query_handler(func=lambda call: call.data.startswith('APP_REC')) 341 | def set_time(call): 342 | """ 343 | Обработка inline callback запросов 344 | Выбор времени 345 | """ 346 | client = clear_dict.CLIENT_DICT.get(call.from_user.id) 347 | if client: 348 | id_client = get_client_id(call.message.chat.id, call.from_user.username) 349 | if client.set_time(id_client): 350 | bot.edit_message_text(chat_id=call.from_user.id, 351 | message_id=call.message.message_id, 352 | text=f'Успешно записал вас!\n\n' 353 | f'🛎️ Услуга: {client.name_service}\n' 354 | f'👤 Мастер: {client.name_master if client.name_master else "Любой"}\n' 355 | f'📅 Дата: {client.date_record}\n' 356 | f'🕓 Время: {client.time_record}', 357 | ) 358 | check_phone_number(call.message) 359 | else: 360 | bot.send_message(call.message.chat.id, 'Время кто-то забронировал...\nПопробуй другое!') 361 | else: 362 | go_to_menu(call) 363 | 364 | 365 | @bot.callback_query_handler(func=lambda call: call.data == 'MENU') 366 | def go_to_menu(call): 367 | """Возвращает в главное меню""" 368 | bot.delete_message(chat_id=call.message.chat.id, 369 | message_id=call.message.message_id) 370 | check_phone_number(call.message) 371 | 372 | 373 | bot.infinity_polling() 374 | --------------------------------------------------------------------------------