Repository: mskozlova/ydb_serverless_telegram_bot Branch: main Commit: 8e62724b564e Files: 20 Total size: 36.1 KB Directory structure: gitextract_24c48paq/ ├── .gitignore ├── LICENSE ├── README.md ├── bot/ │ ├── __init__.py │ ├── handlers.py │ ├── keyboards.py │ ├── states.py │ └── structure.py ├── create_function_version.sh ├── database/ │ ├── __init__.py │ ├── model.py │ ├── queries.py │ ├── utils.py │ └── ydb_settings.py ├── index.py ├── logs.py ├── requirements.txt ├── tests/ │ ├── fixtures.py │ └── utils.py └── user_interaction/ └── texts.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ cover/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder .pybuilder/ target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: # .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # poetry # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # This is especially recommended for binary packages to ensure reproducibility, and is more # commonly ignored for libraries. # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control #poetry.lock # pdm # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. #pdm.lock # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # in version control. # https://pdm.fming.dev/#use-with-ide .pdm.toml # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ # pytype static type analyzer .pytype/ # Cython debug symbols cython_debug/ # PyCharm # JetBrains specific template is maintained in a separate JetBrains.gitignore that can # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ # custom code.zip .DS_Store create_function_version_local.sh ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 mskozlova Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # About This is a simple example of a telegram bot implementation. This code is designed to be run on [Yandex Cloud Serverless Function](https://cloud.yandex.com/en/docs/functions/quickstart/?from=int-console-help-center-or-nav) connected to [YDB database](https://cloud.yandex.com/en/docs/ydb/quickstart?from=int-console-help-center-or-nav) using [TeleBot (pyTelegramBotAPI)](https://pytba.readthedocs.io/en/latest/index.html) python3 package. ## Advantages This repository can be used as a template for creating more complicated bots. This implementation supports: - full logging adapted to Yandex Cloud Functions - handling user's states, which allows to conveniently process each text input in appropriate context and make complicated logics manageable - handling a variety of Reply Keyboards and simple text inputs - testing the bot (TBD) ## What does the bot do List of the bot's functions: - asks for the user's first name, last name and age step-by-step - checks correctness of the input data (age) - saves the info into the database (i.e. 'registers' the user) - shows the info back when required - supports deleting the database entry (i.e. 'deletes the account') You can check out the instance of this bot [here](https://t.me/ydb_serverless_example_bot). # How to set up an instance of the bot ## Creating Yandex Cloud function 1) Visit [Yandex Cloud page](https://cloud.yandex.com/) and click `Console` in upper right corner. Login into Yandex ID, or create an account. 2) In Yandex Cloud console set up Yandex Cloud billing account, if you don't have one. **No payments will be needed to complete this instruction.** 3) In Yandex Console create a folder for your resources. Choose any name.
Screenshot ![Yandex Console Screenshot](screenshots/01-create-folder.png?raw=true "Title")
4) Create a service account with any name and assign it the `editor` and the `serverless.functions.invoker` roles for your folder.
Screenshot ![Yandex Console Screenshot](screenshots/04-create-service-account.png?raw=true "Title")
5) Create an API gateway with any name and the default specification.
Screenshot ![Yandex Console Screenshot](screenshots/06-create-api-gateway.png?raw=true "Title")
6) Create a Serverless Function with Python3.11 environment. Choose any name. In the Editor tab create a first default version, in the Overview tab make it public.
ScreenshotsCreate a function ![Yandex Console Screenshot](screenshots/08-create-function.png?raw=true "Title") Select the environment ![Yandex Console Screenshot](screenshots/08-1-select-environment.png?raw=true "Title") Create a default version ![Yandex Console Screenshot](screenshots/09-create-default-function-version.png?raw=true "Title") Make the function public ![Yandex Console Screenshot](screenshots/08-make-function-public.png?raw=true "Title")
7) Copy your function ID and save for the next step.
Screenshot ![Yandex Console Screenshot](screenshots/10-copy-function-id.png?raw=true "Title")
8) Create a link between the API gateway and the Function - edit the API gateway specification and add the following code in the end, replacing `` with value copied during the last step. Pay attention to the indentation - it should be exactly as in this snippet: ``` /fshtb-function: post: x-yc-apigateway-integration: type: cloud_functions function_id: operationId: fshtb-function ``` ## Creating a bot and linking it with the function 1) Create a telegram bot by sending `/newbot` command for BotFather in Telegram. Give it a name and a login, then receive a token for your bot.
Screenshot
2) (optional) Set up bot commands to create a menu. Send `/setcommands` to `BotFather`, choose your bot from the list and sent the following list of commands. This list will appear when clicking on the button in the bottom left corner of the bot chat.
Commands
  start - show welcome message and bot description
  register - store your name and age in the database
  cancel - stop registering process
  show_data - show your name and age stored in the database
  delete_account - delete your info from the database
  
3) Create a link between the telegram bot and the function. Run the following request from terminal, replacing `` with the token from BotFather and `` with `Default domain` value from Overview tab of your API gateway. All went well if you received response `{"ok":true,"result":true,"description":"Webhook was set"}`. -
Request ``` curl \ --request POST \ --url https://api.telegram.org/bot/setWebhook \ --header 'content-type: application/json' \ --data '{"url": "/fshtb-function"}' ```
-
Request for Windows ``` curl --request POST --url https://api.telegram.org/bot/setWebhook --header "content-type:application/json" --data "{\"url\": \"/fshtb-function\"}" ```

At this stage sending `/start` to your bot should lead to successful POST requests from API gateway and successful Function invocations, which you can track on their respective Logs tabs.
Successful API gateway logs ![Yandex Console Screenshot](screenshots/12-api-gateway-logs.png?raw=true "Title")
Successful function logs ![Yandex Console Screenshot](screenshots/13-function-logs.png?raw=true "Title")

Note: the function does not do anything yet, except for waking up and going back to sleep. ## Creating a YDB database 1) Create a new serverless YDB database resource with any name in your folder.
ScreenshotsCreate YDB database resource ![Yandex Console Screenshot](screenshots/17-create-ydb-database.png?raw=true "Title") Give it any name ![Yandex Console Screenshot](screenshots/18-save-ydb-settings.png?raw=true "Title")
2) Go to Navigation tab of the new YDB database, click `New SQL query` and run the following request to create 2 necessary tables.
Screenshot ![Yandex Console Screenshot](screenshots/19-create-ydb-tables.png?raw=true "Title")
-
SQL script ``` CREATE TABLE `user_personal_info` ( `user_id` Uint64, `last_name` Utf8, `first_name` Utf8, `age` Uint64, PRIMARY KEY (`user_id`) ); COMMIT; CREATE TABLE `states` ( `user_id` Uint64, `state` Utf8, PRIMARY KEY (`user_id`) ); ```

## Make your bot do something 1) Download the code from this repository and in terminal go to the directory, which contains `index.py`. Create a ZIP archive with the directory contents `zip -r ../code.zip *`. The command will create an archive in the parent folder. 2) In Editor tab of function: - Choose the upload method `ZIP archive`. - Click `Attach file` and select the code archive. - Fill `Entrypoint` field with `index.handler`. - Select your service account. - Create 3 environment variables: `YDB_DATABASE`, `YDB_ENDPOINT`, `BOT_TOKEN`.
How to choose their values - `YDB_DATABASE` is a value from YDB database Overview tab: `Connection > Database`. - `YDB_ENDPOINT` is a value from YDB database Overview tab: `Connection > Endpoint`. - `BOT_TOKEN` is the token you received from BotFather after creating the new bot.
How it should look like in GUI - screenshot. ![Yandex Console Screenshot](screenshots/16-create-function-version-gui.png?raw=true "Title")
3) Click `Create version` and wait for it to be created.
Alternatively, you can use command line interface to do that.
Create Function version using CLI 1) Download code from this repository. 2) Edit `create_function_version.sh` - fill the placeholders with your IDs and tokens to set up all the necessary version parameters. 3) Prepare Yandex Cloud command line interface - [instruction](https://cloud.yandex.com/en/docs/cli/quickstart). 4) Execute `create_function_version.sh` to create a ZIP archive with the code and create a new version of your function using Yandex Cloud CLI.

Awesome! Now try your bot! ## What next? 1) Play around with the bot.
Bot command examples - screenshots`/start`

`/register`
2) Visit function's Logs tab to see logs for each input message an debug errors if something went wrong. Click the `eye` icon (`JSON` column) on each log to see additional details.
Function logs - screenshot
3. Check out the database tables' contents in YDB database Navigation tab.
YDB table - screenshot
# Testing TBD.. ================================================ FILE: bot/__init__.py ================================================ ================================================ FILE: bot/handlers.py ================================================ from bot import keyboards, states from database import model as db_model from logs import logged_execution from user_interaction import texts @logged_execution def handle_start(message, bot, pool): bot.send_message(message.chat.id, texts.START, reply_markup=keyboards.EMPTY) @logged_execution def handle_register(message, bot, pool): current_data = db_model.get_user_info(pool, message.from_user.id) if current_data: bot.send_message( message.chat.id, texts.ALREADY_REGISTERED.format( current_data["first_name"], current_data["last_name"], current_data["age"], ), reply_markup=keyboards.EMPTY, ) return bot.send_message( message.chat.id, texts.FIRST_NAME, reply_markup=keyboards.get_reply_keyboard(["/cancel"]), ) bot.set_state( message.from_user.id, states.RegisterState.first_name, message.chat.id ) @logged_execution def handle_cancel_registration(message, bot, pool): bot.delete_state(message.from_user.id, message.chat.id) bot.send_message( message.chat.id, texts.CANCEL_REGISTER, reply_markup=keyboards.EMPTY, ) @logged_execution def handle_get_first_name(message, bot, pool): with bot.retrieve_data(message.from_user.id, message.chat.id) as data: data["first_name"] = message.text bot.set_state(message.from_user.id, states.RegisterState.last_name, message.chat.id) bot.send_message( message.chat.id, texts.LAST_NAME, reply_markup=keyboards.get_reply_keyboard(["/cancel"]), ) @logged_execution def handle_get_last_name(message, bot, pool): with bot.retrieve_data(message.from_user.id, message.chat.id) as data: data["last_name"] = message.text bot.set_state(message.from_user.id, states.RegisterState.age, message.chat.id) bot.send_message( message.chat.id, texts.AGE, reply_markup=keyboards.get_reply_keyboard(["/cancel"]), ) @logged_execution def handle_get_age(message, bot, pool): if not message.text.isdigit(): bot.send_message( message.chat.id, texts.AGE_IS_NOT_NUMBER, reply_markup=keyboards.EMPTY, ) return with bot.retrieve_data(message.from_user.id, message.chat.id) as data: first_name = data["first_name"] last_name = data["last_name"] age = int(message.text) bot.delete_state(message.from_user.id, message.chat.id) db_model.add_user_info(pool, message.from_user.id, first_name, last_name, age) bot.send_message( message.chat.id, texts.DATA_IS_SAVED.format(first_name, last_name, age), reply_markup=keyboards.EMPTY, ) @logged_execution def handle_show_data(message, bot, pool): current_data = db_model.get_user_info(pool, message.from_user.id) if not current_data: bot.send_message( message.chat.id, texts.NOT_REGISTERED, reply_markup=keyboards.EMPTY ) return bot.send_message( message.chat.id, texts.SHOW_DATA_WITH_PREFIX.format( current_data["first_name"], current_data["last_name"], current_data["age"] ), reply_markup=keyboards.EMPTY, ) @logged_execution def handle_delete_account(message, bot, pool): current_data = db_model.get_user_info(pool, message.from_user.id) if not current_data: bot.send_message( message.chat.id, texts.NOT_REGISTERED, reply_markup=keyboards.EMPTY ) return bot.send_message( message.chat.id, texts.DELETE_ACCOUNT, reply_markup=keyboards.get_reply_keyboard(texts.DELETE_ACCOUNT_OPTIONS), ) bot.set_state( message.from_user.id, states.DeleteAccountState.are_you_sure, message.chat.id ) @logged_execution def handle_finish_delete_account(message, bot, pool): bot.delete_state(message.from_user.id, message.chat.id) if message.text not in texts.DELETE_ACCOUNT_OPTIONS: bot.send_message( message.chat.id, texts.DELETE_ACCOUNT_UNKNOWN, reply_markup=keyboards.EMPTY, ) return if texts.DELETE_ACCOUNT_OPTIONS[message.text]: db_model.delete_user_info(pool, message.from_user.id) bot.send_message( message.chat.id, texts.DELETE_ACCOUNT_DONE, reply_markup=keyboards.EMPTY, ) else: bot.send_message( message.chat.id, texts.DELETE_ACCOUNT_CANCEL, reply_markup=keyboards.EMPTY, ) ================================================ FILE: bot/keyboards.py ================================================ from telebot import types EMPTY = types.ReplyKeyboardRemove() def get_reply_keyboard(options, additional=None, **kwargs): row_width = kwargs.get("row_width", len(options)) markup = types.ReplyKeyboardMarkup( row_width=row_width, resize_keyboard=True, one_time_keyboard=True, ) markup.add(*options, row_width=row_width) if additional: markup.add(*additional, row_width=len(additional)) return markup ================================================ FILE: bot/states.py ================================================ from telebot.handler_backends import State, StatesGroup from telebot.storage.base_storage import StateContext, StateStorageBase from database import model as db_model # based on Telebot example # https://github.com/eternnoir/pyTelegramBotAPI/blob/0f52ca688ffb7af6176d2f73fca92335dc3560eb/telebot/storage/redis_storage.py class StateYDBStorage(StateStorageBase): """ This class is for YDB storage to be used by the bot to track user states. """ def __init__(self, ydb_pool): super().__init__() self.pool = ydb_pool def set_data(self, chat_id, user_id, key, value): """ Set data for a user in a particular chat. """ if db_model.get_state(self.pool, user_id) is None: return False full_state = db_model.get_state(self.pool, user_id) full_state["data"][key] = value db_model.set_state(self.pool, user_id, full_state) return True def get_data(self, chat_id, user_id): """ Get data for a user in a particular chat. """ full_state = db_model.get_state(self.pool, user_id) if full_state: return full_state.get("data", {}) return {} def set_state(self, chat_id, user_id, state): if hasattr(state, "name"): state = state.name data = self.get_data(chat_id, user_id) full_state = {"state": state, "data": data} db_model.set_state(self.pool, user_id, full_state) return True def delete_state(self, chat_id, user_id): """ Delete state for a particular user. """ if db_model.get_state(self.pool, user_id) is None: return False db_model.clear_state(self.pool, user_id) return True def reset_data(self, chat_id, user_id): """ Reset data for a particular user in a chat. """ full_state = db_model.get_state(self.pool, user_id) if full_state: full_state["data"] = {} db_model.set_state(self.pool, user_id, full_state) return True return False def get_state(self, chat_id, user_id): states = db_model.get_state(self.pool, user_id) if states is None: return None return states.get("state") def get_interactive_data(self, chat_id, user_id): return StateContext(self, chat_id, user_id) def save(self, chat_id, user_id, data): full_state = db_model.get_state(self.pool, user_id) if full_state: full_state["data"] = data db_model.set_state(self.pool, user_id, full_state) return True class RegisterState(StatesGroup): first_name = State() last_name = State() age = State() class DeleteAccountState(StatesGroup): are_you_sure = State() ================================================ FILE: bot/structure.py ================================================ from functools import partial from telebot import TeleBot, custom_filters from bot import handlers as handlers from bot import states as bot_states # import tests.handlers as test_handlers class Handler: def __init__(self, callback, **kwargs): self.callback = callback self.kwargs = kwargs def get_start_handlers(): return [ Handler(callback=handlers.handle_start, commands=["start"]), ] def get_registration_handlers(): return [ Handler(callback=handlers.handle_register, commands=["register"]), Handler( callback=handlers.handle_cancel_registration, commands=["cancel"], state=[ bot_states.RegisterState.first_name, bot_states.RegisterState.last_name, bot_states.RegisterState.age, ], ), Handler( callback=handlers.handle_get_first_name, state=bot_states.RegisterState.first_name, ), Handler( callback=handlers.handle_get_last_name, state=bot_states.RegisterState.last_name, ), Handler(callback=handlers.handle_get_age, state=bot_states.RegisterState.age), ] def get_show_data_handlers(): return [ Handler(callback=handlers.handle_show_data, commands=["show_data"]), ] def get_delete_account_handlers(): return [ Handler(callback=handlers.handle_delete_account, commands=["delete_account"]), Handler( callback=handlers.handle_finish_delete_account, state=bot_states.DeleteAccountState.are_you_sure, ), ] def create_bot(bot_token, pool): state_storage = bot_states.StateYDBStorage(pool) bot = TeleBot(bot_token, state_storage=state_storage) handlers = [] handlers.extend(get_start_handlers()) handlers.extend(get_registration_handlers()) handlers.extend(get_show_data_handlers()) handlers.extend(get_delete_account_handlers()) for handler in handlers: bot.register_message_handler( partial(handler.callback, pool=pool), **handler.kwargs, pass_bot=True ) bot.add_custom_filter(custom_filters.StateFilter(bot)) return bot ================================================ FILE: create_function_version.sh ================================================ export function_id="" export service_account_id="" export ydb_database="" export ydb_endpoint="" export bot_token="" zip code *.py *.md *.txt database/* user_interaction/* bot/* tests/*.py && yc serverless function version create \ --function-id="$function_id" \ --runtime python311 \ --entrypoint index.handler \ --memory 128m \ --execution-timeout 40s \ --source-path code.zip \ --service-account-id="$service_account_id" \ --environment YDB_DATABASE="$ydb_database" \ --environment YDB_ENDPOINT="$ydb_endpoint" \ --environment BOT_TOKEN="$bot_token" ================================================ FILE: database/__init__.py ================================================ ================================================ FILE: database/model.py ================================================ import json from database import queries from database.utils import execute_select_query, execute_update_query def get_state(pool, user_id): results = execute_select_query(pool, queries.get_user_state, user_id=user_id) if len(results) == 0: return None if results[0]["state"] is None: return None return json.loads(results[0]["state"]) def set_state(pool, user_id, state): execute_update_query( pool, queries.set_user_state, user_id=user_id, state=json.dumps(state) ) def clear_state(pool, user_id): execute_update_query(pool, queries.set_user_state, user_id=user_id, state=None) def add_user_info(pool, user_id, first_name, last_name, age): execute_update_query( pool, queries.add_user_info, user_id=user_id, first_name=first_name, last_name=last_name, age=age, ) def get_user_info(pool, user_id): result = execute_select_query(pool, queries.get_user_info, user_id=user_id) if len(result) != 1: return None return result[0] def delete_user_info(pool, user_id): execute_update_query(pool, queries.delete_user_info, user_id=user_id) ================================================ FILE: database/queries.py ================================================ USERS_INFO_TABLE_PATH = "user_personal_info" STATES_TABLE_PATH = "states" get_user_state = f""" DECLARE $user_id AS Uint64; SELECT state FROM `{STATES_TABLE_PATH}` WHERE user_id == $user_id; """ set_user_state = f""" DECLARE $user_id AS Uint64; DECLARE $state AS Utf8?; UPSERT INTO `{STATES_TABLE_PATH}` (`user_id`, `state`) VALUES ($user_id, $state); """ get_user_info = f""" DECLARE $user_id AS Int64; SELECT user_id, age, first_name, last_name FROM `{USERS_INFO_TABLE_PATH}` WHERE user_id == $user_id; """ add_user_info = f""" DECLARE $user_id AS Uint64; DECLARE $first_name AS Utf8; DECLARE $last_name AS Utf8; DECLARE $age AS Uint64; INSERT INTO `{USERS_INFO_TABLE_PATH}` (user_id, first_name, last_name, age) VALUES ($user_id, $first_name, $last_name, $age); """ delete_user_info = f""" DECLARE $user_id AS Uint64; DELETE FROM `{USERS_INFO_TABLE_PATH}` WHERE user_id == $user_id; DELETE FROM `{STATES_TABLE_PATH}` WHERE user_id == $user_id; """ ================================================ FILE: database/utils.py ================================================ import ydb def _format_kwargs(kwargs): return {"${}".format(key): value for key, value in kwargs.items()} # using prepared statements # https://ydb.tech/en/docs/reference/ydb-sdk/example/python/#param-prepared-queries def execute_update_query(pool, query, **kwargs): def callee(session): prepared_query = session.prepare(query) session.transaction(ydb.SerializableReadWrite()).execute( prepared_query, _format_kwargs(kwargs), commit_tx=True ) return pool.retry_operation_sync(callee) # using prepared statements # https://ydb.tech/en/docs/reference/ydb-sdk/example/python/#param-prepared-queries def execute_select_query(pool, query, **kwargs): def callee(session): prepared_query = session.prepare(query) result_sets = session.transaction(ydb.SerializableReadWrite()).execute( prepared_query, _format_kwargs(kwargs), commit_tx=True ) return result_sets[0].rows return pool.retry_operation_sync(callee) ================================================ FILE: database/ydb_settings.py ================================================ import ydb def get_ydb_pool(ydb_endpoint, ydb_database, timeout=30): ydb_driver_config = ydb.DriverConfig( ydb_endpoint, ydb_database, credentials=ydb.credentials_from_env_variables(), root_certificates=ydb.load_ydb_root_certificate(), ) ydb_driver = ydb.Driver(ydb_driver_config) ydb_driver.wait(fail_fast=True, timeout=timeout) return ydb.SessionPool(ydb_driver) ================================================ FILE: index.py ================================================ import os import telebot from bot.structure import create_bot from database.ydb_settings import get_ydb_pool from logs import logger YDB_ENDPOINT = os.getenv("YDB_ENDPOINT") YDB_DATABASE = os.getenv("YDB_DATABASE") BOT_TOKEN = os.getenv("BOT_TOKEN") def handler(event, _): logger.debug(f"New event: {event}") pool = get_ydb_pool(YDB_ENDPOINT, YDB_DATABASE) bot = create_bot(BOT_TOKEN, pool) message = telebot.types.Update.de_json(event["body"]) bot.process_new_updates([message]) return { "statusCode": 200, "body": "!", } ================================================ FILE: logs.py ================================================ import logging import traceback from pythonjsonlogger import jsonlogger from telebot.types import Message # https://cloud.yandex.com/en/docs/functions/operations/function/logs-write#function-examples class YcLoggingFormatter(jsonlogger.JsonFormatter): def add_fields(self, log_record, record, message_dict): super(YcLoggingFormatter, self).add_fields(log_record, record, message_dict) log_record["logger"] = record.name log_record["level"] = str.replace( str.replace(record.levelname, "WARNING", "WARN"), "CRITICAL", "FATAL" ) logHandler = logging.StreamHandler() logHandler.setFormatter(YcLoggingFormatter("%(message)s %(level)s %(logger)s")) logger = logging.getLogger("logger") logger.addHandler(logHandler) logger.setLevel(logging.DEBUG) def find_in_args(args, target_type): for arg in args: if isinstance(arg, target_type): return arg def find_in_kwargs(kwargs, target_type): return find_in_args(kwargs.values(), target_type) def get_message_info(*args, **kwargs): message_args = find_in_args(args, Message) if message_args is not None: return message_args.chat.id, message_args.text message_kwargs = find_in_kwargs(kwargs, Message) if message_kwargs is not None: return message_kwargs.chat.id, message_kwargs.text return "UNKNOWN", "UNKNOWN" def logged_execution(func): def wrapper(*args, **kwargs): chat_id, text = get_message_info(*args, **kwargs) logger.info( f"[LOG] Starting {func.__name__} - chat_id {chat_id}", extra={ "text": text, "arg": str(args), "kwarg": str(kwargs), }, ) try: func(*args, **kwargs) logger.info( f"[LOG] Finished {func.__name__} - chat_id {chat_id}", extra={ "text": text, "arg": str(args), "kwarg": str(kwargs), }, ) except Exception as e: logger.error( f"[LOG] Failed {func.__name__} - chat_id {chat_id} - exception {e}", extra={ "text": text, "arg": str(args), "kwarg": str(kwargs), "error": e, "traceback": traceback.format_exc(), }, ) return wrapper ================================================ FILE: requirements.txt ================================================ pyTelegramBotAPI==4.6.0 ydb==3.3.1 python-json-logger==2.0.7 protobuf==3.20.0 ================================================ FILE: tests/fixtures.py ================================================ import os import pytest from pyrogram import Client api_id = os.environ.get("TELEGRAM_API_ID") api_hash = os.environ.get("TELEGRAM_API_HASH") client_name = "languagecardsbottester" workdir = "/Users/mariakozlova/ml_and_staff/language_cards_bot/tests" @pytest.fixture def test_client(): client = Client(client_name, api_id, api_hash, workdir=workdir) client.start() yield client client.stop() @pytest.fixture def chat_id(): return "@language_cards_tester_bot" ================================================ FILE: tests/utils.py ================================================ from time import sleep class CommandContext: def __init__(self, client, chat_id, command): self.client = client self.chat_id = chat_id self.command = command self.step = 1 def __enter__(self): self.message = self.client.send_message(chat_id=self.chat_id, text=self.command) return self def __exit__(self, exc_type, exc_val, exc_tb): return def expect_next(self, correct_response, sleep_s=0.2, timeout_s=60): assert correct_response is not None, "correct_response should be specified" timer = 0 while timer <= timeout_s: response = self.client.get_messages( self.chat_id, self.message.id + self.step ).text if response is not None: # found target message self.step += 1 assert response == correct_response, ( f"'{self.command}' failed due to wrong reaction on step {self.step - 1}" f"\nreaction: {response}\nexpected: {correct_response}" ) return timer += sleep_s sleep(sleep_s) def expect_next_prefix(self, correct_response_prefix, sleep_s=0.2, timeout_s=60): assert ( correct_response_prefix is not None ), "correct_response should be specified" timer = 0 while timer <= timeout_s: response = self.client.get_messages( self.chat_id, self.message.id + self.step ).text if response is not None: # found target message self.step += 1 assert response.startswith(correct_response_prefix), ( f"'{self.command}' failed due to wrong reaction prefix on step {self.step - 1}" f"\nreaction: {response}\nexpected: {correct_response_prefix}" ) return timer += sleep_s sleep(sleep_s) def expect_none(self, sleep_s=0.5, timeout_s=2): timer = 0 while timer <= timeout_s: response = self.client.get_messages( self.chat_id, self.message.id + self.step ).text assert response is None, ( f"'{self.command}' failed due to presence of reaction on step {self.step}" f"\nreaction: {response}" ) timer += sleep_s sleep(sleep_s) def expect_length(self, num_rows, sleep_s=0.5, timeout_s=60): raise NotImplementedError def expect_any(self, sleep_s=0.2, timeout_s=60): timer = 0 while timer <= timeout_s: response = self.client.get_messages( self.chat_id, self.message.id + self.step ).text if response is not None: # found target message self.step += 1 break timer += sleep_s sleep(sleep_s) assert ( response is not None ), f"'{self.command}' failed due to absence of reaction on step {self.step - 1}" def expect_any_multiple(self, number_of_responses, sleep_s=0.2, timeout_s=60): for i in range(number_of_responses): self.expect_any(sleep_s=sleep_s, timeout_s=timeout_s) def expect_next_number_of_rows(self, n_rows, sleep_s=0.2, timeout_s=60): timer = 0 while timer <= timeout_s: response = self.client.get_messages( self.chat_id, self.message.id + self.step ).text if response is not None: # found target message self.step += 1 assert len(response.split("\n")) == n_rows, ( f"'{self.command}' failed due to wrong number of rows on step {self.step - 1}" f"\nreaction: {response}\nexpected number of rows: {n_rows}" ) return timer += sleep_s sleep(sleep_s) ================================================ FILE: user_interaction/texts.py ================================================ START = ( "Hello! This is a simple bot that can store your name and age, " "show them back to you and delete them if requested.\n\n" "List of commands:\n" "/start\n" "/register\n" "/show_data\n" "/delete_account" ) FIRST_NAME = "Enter your first name." LAST_NAME = "Enter your last name." AGE = "Enter your age." AGE_IS_NOT_NUMBER = "Age should be a positive number, try again." SHOW_DATA = "First name: {}\nLast name: {}\nAge: {}" DATA_IS_SAVED = "Your data is saved!\n" + SHOW_DATA ALREADY_REGISTERED = "You are already registered!\n" + SHOW_DATA SHOW_DATA_WITH_PREFIX = "Your data:\n" + SHOW_DATA NOT_REGISTERED = "You are not registered yet, try /register." CANCEL_REGISTER = "Cancelled! Your data is not saved." DELETE_ACCOUNT = "Are you sure you want to delete your account?" DELETE_ACCOUNT_OPTIONS = {"Yes!": True, "No..": False} DELETE_ACCOUNT_UNKNOWN = "I don't understand this command." DELETE_ACCOUNT_DONE = "Done! You can /register again." DELETE_ACCOUNT_CANCEL = "Ok, stay for longer!"