[
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n\n# custom\ncode.zip\n.DS_Store\ncreate_function_version_local.sh\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2023 mskozlova\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# About\nThis 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.\n\n## Advantages\n\nThis repository can be used as a template for creating more complicated bots.\n\nThis implementation supports:\n- full logging adapted to Yandex Cloud Functions\n- handling user's states, which allows to conveniently process each text input in appropriate context and make complicated logics manageable\n- handling a variety of Reply Keyboards and simple text inputs\n- testing the bot (TBD)\n\n## What does the bot do\nList of the bot's functions:\n- asks for the user's first name, last name and age step-by-step\n- checks correctness of the input data (age)\n- saves the info into the database (i.e. 'registers' the user)\n- shows the info back when required\n- supports deleting the database entry (i.e. 'deletes the account')\n\nYou can check out the instance of this bot [here](https://t.me/ydb_serverless_example_bot).\n\n# How to set up an instance of the bot\n\n## Creating Yandex Cloud function\n\n1) Visit [Yandex Cloud page](https://cloud.yandex.com/) and click `Console` in upper right corner. Login into Yandex ID, or create an account.\n2) 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.**\n3) In Yandex Console create a folder for your resources. Choose any name. <details><summary>Screenshot</summary>\n![Yandex Console Screenshot](screenshots/01-create-folder.png?raw=true \"Title\")</details>\n4) Create a service account with any name and assign it the `editor` and the `serverless.functions.invoker` roles for your folder. <details><summary>Screenshot</summary>\n![Yandex Console Screenshot](screenshots/04-create-service-account.png?raw=true \"Title\")</details>\n5) Create an API gateway with any name and the default specification. <details><summary>Screenshot</summary>\n![Yandex Console Screenshot](screenshots/06-create-api-gateway.png?raw=true \"Title\")</details>\n6) 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. <details><summary>Screenshots</summary>Create a function\n![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\")</details>\n7) Copy your function ID and save for the next step. <details><summary>Screenshot</summary>\n![Yandex Console Screenshot](screenshots/10-copy-function-id.png?raw=true \"Title\")</details>\n8) Create a link between the API gateway and the Function - edit the API gateway specification and add the following code in the end, replacing `<function ID>` with value copied during the last step. Pay attention to the indentation - it should be exactly as in this snippet:\n```\n  /fshtb-function:\n    post:\n      x-yc-apigateway-integration:\n        type: cloud_functions\n        function_id: <function ID>\n      operationId: fshtb-function\n```\n\n## Creating a bot and linking it with the function\n1) 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. <details><summary>Screenshot</summary>\n<img src=\"screenshots/05-create-telegram-bot.png\" width=\"400\"></details>\n2) (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. <details><summary>Commands</summary><pre>\n  start - show welcome message and bot description\n  register - store your name and age in the database\n  cancel - stop registering process\n  show_data - show your name and age stored in the database\n  delete_account - delete your info from the database\n  </pre><img src=\"screenshots/05-1_bot_commands.png\" width=\"400\">\n</details>\n\n3) Create a link between the telegram bot and the function. Run the following request from terminal, replacing `<YOUR BOT TOKEN>` with the token from BotFather and `<API gateway domain>` 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\"}`.\n- <details><summary>Request</summary>\n\n  ```\n  curl \\\n    --request POST \\\n    --url https://api.telegram.org/bot<YOUR BOT TOKEN>/setWebhook \\\n    --header 'content-type: application/json' \\\n    --data '{\"url\": \"<API gateway domain>/fshtb-function\"}'\n  ```\n\n</details>\n\n- <details><summary>Request for Windows</summary>\n\n  ```\n  curl --request POST --url https://api.telegram.org/bot<YOUR BOT TOKEN>/setWebhook --header \"content-type:application/json\" --data \"{\\\"url\\\": \\\"<API gateway domain>/fshtb-function\\\"}\"\n  ```\n\n</details>\n</br>\n\nAt 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.\n<details><summary>Successful API gateway logs</summary>\n\n![Yandex Console Screenshot](screenshots/12-api-gateway-logs.png?raw=true \"Title\")\n</details>\n<details><summary>Successful function logs</summary>\n\n![Yandex Console Screenshot](screenshots/13-function-logs.png?raw=true \"Title\")\n</details>\n</br>\nNote: the function does not do anything yet, except for waking up and going back to sleep.\n\n## Creating a YDB database\n1) Create a new serverless YDB database resource with any name in your folder. <details><summary>Screenshots</summary>Create 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\")\n</details>\n\n2) Go to Navigation tab of the new YDB database, click `New SQL query` and run the following request to create 2 necessary tables. <details><summary>Screenshot</summary>\n![Yandex Console Screenshot](screenshots/19-create-ydb-tables.png?raw=true \"Title\")\n</details>\n\n- <details><summary>SQL script</summary>\n\n  ```\n  CREATE TABLE `user_personal_info`\n  (\n    `user_id` Uint64,\n    `last_name` Utf8,\n    `first_name` Utf8,\n    `age` Uint64,\n    PRIMARY KEY (`user_id`)\n  );\n\n  COMMIT;\n\n  CREATE TABLE `states`\n  (\n    `user_id` Uint64,\n    `state` Utf8,\n    PRIMARY KEY (`user_id`)\n  );\n  ```\n\n</details>\n\n</br>\n\n\n## Make your bot do something\n1) 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.\n2) In Editor tab of function:\n    - Choose the upload method `ZIP archive`.\n    - Click `Attach file` and select the code archive.\n    - Fill `Entrypoint` field with `index.handler`.\n    - Select your service account.\n    - Create 3 environment variables: `YDB_DATABASE`, `YDB_ENDPOINT`, `BOT_TOKEN`. <details><summary>How to choose their values</summary>\n      - `YDB_DATABASE` is a value from YDB database Overview tab: `Connection > Database`.\n      - `YDB_ENDPOINT` is a value from YDB database Overview tab: `Connection > Endpoint`.\n      - `BOT_TOKEN` is the token you received from BotFather after creating the new bot.</details> <details><summary>How it should look like in GUI - screenshot.</summary>\n      ![Yandex Console Screenshot](screenshots/16-create-function-version-gui.png?raw=true \"Title\")\n      </details>\n3) Click `Create version` and wait for it to be created.\n\n</br>\n<b>Alternatively</b>, you can use command line interface to do that.\n<details><summary>Create Function version using CLI</summary>\n\n1) Download code from this repository.\n2) Edit `create_function_version.sh` - fill the placeholders with your IDs and tokens to set up all the necessary version parameters.\n3) Prepare Yandex Cloud command line interface - [instruction](https://cloud.yandex.com/en/docs/cli/quickstart).\n4) 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.\n</details>\n\n</br>\n<b>Awesome! Now try your bot!</b>\n\n## What next?\n1) Play around with the bot. <details><summary>Bot command examples - screenshots</summary>`/start`</br><img src=\"screenshots/20-bot_start.png\" width=\"300\"></br>`/register`</br><img src=\"screenshots/21-bot_register.png\" width=\"300\">\n</details>\n\n2) 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.<details><summary>Function logs - screenshot</summary><img src=\"screenshots/22-function-logs.png\" width=\"800\">\n</details>\n\n3. Check out the database tables' contents in YDB database Navigation tab.<details><summary>YDB table - screenshot</summary><img src=\"screenshots/23-ydb-after-register.png\" width=\"800\">\n</details>\n\n# Testing\nTBD..\n"
  },
  {
    "path": "bot/__init__.py",
    "content": ""
  },
  {
    "path": "bot/handlers.py",
    "content": "from bot import keyboards, states\nfrom database import model as db_model\nfrom logs import logged_execution\nfrom user_interaction import texts\n\n\n@logged_execution\ndef handle_start(message, bot, pool):\n    bot.send_message(message.chat.id, texts.START, reply_markup=keyboards.EMPTY)\n\n\n@logged_execution\ndef handle_register(message, bot, pool):\n    current_data = db_model.get_user_info(pool, message.from_user.id)\n\n    if current_data:\n        bot.send_message(\n            message.chat.id,\n            texts.ALREADY_REGISTERED.format(\n                current_data[\"first_name\"],\n                current_data[\"last_name\"],\n                current_data[\"age\"],\n            ),\n            reply_markup=keyboards.EMPTY,\n        )\n        return\n\n    bot.send_message(\n        message.chat.id,\n        texts.FIRST_NAME,\n        reply_markup=keyboards.get_reply_keyboard([\"/cancel\"]),\n    )\n    bot.set_state(\n        message.from_user.id, states.RegisterState.first_name, message.chat.id\n    )\n\n\n@logged_execution\ndef handle_cancel_registration(message, bot, pool):\n    bot.delete_state(message.from_user.id, message.chat.id)\n    bot.send_message(\n        message.chat.id,\n        texts.CANCEL_REGISTER,\n        reply_markup=keyboards.EMPTY,\n    )\n\n\n@logged_execution\ndef handle_get_first_name(message, bot, pool):\n    with bot.retrieve_data(message.from_user.id, message.chat.id) as data:\n        data[\"first_name\"] = message.text\n    bot.set_state(message.from_user.id, states.RegisterState.last_name, message.chat.id)\n    bot.send_message(\n        message.chat.id,\n        texts.LAST_NAME,\n        reply_markup=keyboards.get_reply_keyboard([\"/cancel\"]),\n    )\n\n\n@logged_execution\ndef handle_get_last_name(message, bot, pool):\n    with bot.retrieve_data(message.from_user.id, message.chat.id) as data:\n        data[\"last_name\"] = message.text\n    bot.set_state(message.from_user.id, states.RegisterState.age, message.chat.id)\n    bot.send_message(\n        message.chat.id,\n        texts.AGE,\n        reply_markup=keyboards.get_reply_keyboard([\"/cancel\"]),\n    )\n\n\n@logged_execution\ndef handle_get_age(message, bot, pool):\n    if not message.text.isdigit():\n        bot.send_message(\n            message.chat.id,\n            texts.AGE_IS_NOT_NUMBER,\n            reply_markup=keyboards.EMPTY,\n        )\n        return\n\n    with bot.retrieve_data(message.from_user.id, message.chat.id) as data:\n        first_name = data[\"first_name\"]\n        last_name = data[\"last_name\"]\n        age = int(message.text)\n\n    bot.delete_state(message.from_user.id, message.chat.id)\n    db_model.add_user_info(pool, message.from_user.id, first_name, last_name, age)\n\n    bot.send_message(\n        message.chat.id,\n        texts.DATA_IS_SAVED.format(first_name, last_name, age),\n        reply_markup=keyboards.EMPTY,\n    )\n\n\n@logged_execution\ndef handle_show_data(message, bot, pool):\n    current_data = db_model.get_user_info(pool, message.from_user.id)\n\n    if not current_data:\n        bot.send_message(\n            message.chat.id, texts.NOT_REGISTERED, reply_markup=keyboards.EMPTY\n        )\n        return\n\n    bot.send_message(\n        message.chat.id,\n        texts.SHOW_DATA_WITH_PREFIX.format(\n            current_data[\"first_name\"], current_data[\"last_name\"], current_data[\"age\"]\n        ),\n        reply_markup=keyboards.EMPTY,\n    )\n\n\n@logged_execution\ndef handle_delete_account(message, bot, pool):\n    current_data = db_model.get_user_info(pool, message.from_user.id)\n    if not current_data:\n        bot.send_message(\n            message.chat.id, texts.NOT_REGISTERED, reply_markup=keyboards.EMPTY\n        )\n        return\n\n    bot.send_message(\n        message.chat.id,\n        texts.DELETE_ACCOUNT,\n        reply_markup=keyboards.get_reply_keyboard(texts.DELETE_ACCOUNT_OPTIONS),\n    )\n    bot.set_state(\n        message.from_user.id, states.DeleteAccountState.are_you_sure, message.chat.id\n    )\n\n\n@logged_execution\ndef handle_finish_delete_account(message, bot, pool):\n    bot.delete_state(message.from_user.id, message.chat.id)\n\n    if message.text not in texts.DELETE_ACCOUNT_OPTIONS:\n        bot.send_message(\n            message.chat.id,\n            texts.DELETE_ACCOUNT_UNKNOWN,\n            reply_markup=keyboards.EMPTY,\n        )\n        return\n\n    if texts.DELETE_ACCOUNT_OPTIONS[message.text]:\n        db_model.delete_user_info(pool, message.from_user.id)\n        bot.send_message(\n            message.chat.id,\n            texts.DELETE_ACCOUNT_DONE,\n            reply_markup=keyboards.EMPTY,\n        )\n    else:\n        bot.send_message(\n            message.chat.id,\n            texts.DELETE_ACCOUNT_CANCEL,\n            reply_markup=keyboards.EMPTY,\n        )\n"
  },
  {
    "path": "bot/keyboards.py",
    "content": "from telebot import types\n\nEMPTY = types.ReplyKeyboardRemove()\n\n\ndef get_reply_keyboard(options, additional=None, **kwargs):\n    row_width = kwargs.get(\"row_width\", len(options))\n\n    markup = types.ReplyKeyboardMarkup(\n        row_width=row_width,\n        resize_keyboard=True,\n        one_time_keyboard=True,\n    )\n    markup.add(*options, row_width=row_width)\n    if additional:\n        markup.add(*additional, row_width=len(additional))\n\n    return markup\n"
  },
  {
    "path": "bot/states.py",
    "content": "from telebot.handler_backends import State, StatesGroup\nfrom telebot.storage.base_storage import StateContext, StateStorageBase\n\nfrom database import model as db_model\n\n\n# based on Telebot example\n# https://github.com/eternnoir/pyTelegramBotAPI/blob/0f52ca688ffb7af6176d2f73fca92335dc3560eb/telebot/storage/redis_storage.py\nclass StateYDBStorage(StateStorageBase):\n    \"\"\"\n    This class is for YDB storage to be used by the bot to track user states.\n    \"\"\"\n\n    def __init__(self, ydb_pool):\n        super().__init__()\n        self.pool = ydb_pool\n\n    def set_data(self, chat_id, user_id, key, value):\n        \"\"\"\n        Set data for a user in a particular chat.\n        \"\"\"\n        if db_model.get_state(self.pool, user_id) is None:\n            return False\n\n        full_state = db_model.get_state(self.pool, user_id)\n        full_state[\"data\"][key] = value\n\n        db_model.set_state(self.pool, user_id, full_state)\n        return True\n\n    def get_data(self, chat_id, user_id):\n        \"\"\"\n        Get data for a user in a particular chat.\n        \"\"\"\n        full_state = db_model.get_state(self.pool, user_id)\n        if full_state:\n            return full_state.get(\"data\", {})\n\n        return {}\n\n    def set_state(self, chat_id, user_id, state):\n        if hasattr(state, \"name\"):\n            state = state.name\n\n        data = self.get_data(chat_id, user_id)\n        full_state = {\"state\": state, \"data\": data}\n        db_model.set_state(self.pool, user_id, full_state)\n        return True\n\n    def delete_state(self, chat_id, user_id):\n        \"\"\"\n        Delete state for a particular user.\n        \"\"\"\n        if db_model.get_state(self.pool, user_id) is None:\n            return False\n\n        db_model.clear_state(self.pool, user_id)\n        return True\n\n    def reset_data(self, chat_id, user_id):\n        \"\"\"\n        Reset data for a particular user in a chat.\n        \"\"\"\n        full_state = db_model.get_state(self.pool, user_id)\n        if full_state:\n            full_state[\"data\"] = {}\n            db_model.set_state(self.pool, user_id, full_state)\n            return True\n        return False\n\n    def get_state(self, chat_id, user_id):\n        states = db_model.get_state(self.pool, user_id)\n        if states is None:\n            return None\n        return states.get(\"state\")\n\n    def get_interactive_data(self, chat_id, user_id):\n        return StateContext(self, chat_id, user_id)\n\n    def save(self, chat_id, user_id, data):\n        full_state = db_model.get_state(self.pool, user_id)\n        if full_state:\n            full_state[\"data\"] = data\n            db_model.set_state(self.pool, user_id, full_state)\n            return True\n\n\nclass RegisterState(StatesGroup):\n    first_name = State()\n    last_name = State()\n    age = State()\n\n\nclass DeleteAccountState(StatesGroup):\n    are_you_sure = State()\n"
  },
  {
    "path": "bot/structure.py",
    "content": "from functools import partial\n\nfrom telebot import TeleBot, custom_filters\n\nfrom bot import handlers as handlers\nfrom bot import states as bot_states\n\n# import tests.handlers as test_handlers\n\n\nclass Handler:\n    def __init__(self, callback, **kwargs):\n        self.callback = callback\n        self.kwargs = kwargs\n\n\ndef get_start_handlers():\n    return [\n        Handler(callback=handlers.handle_start, commands=[\"start\"]),\n    ]\n\n\ndef get_registration_handlers():\n    return [\n        Handler(callback=handlers.handle_register, commands=[\"register\"]),\n        Handler(\n            callback=handlers.handle_cancel_registration,\n            commands=[\"cancel\"],\n            state=[\n                bot_states.RegisterState.first_name,\n                bot_states.RegisterState.last_name,\n                bot_states.RegisterState.age,\n            ],\n        ),\n        Handler(\n            callback=handlers.handle_get_first_name,\n            state=bot_states.RegisterState.first_name,\n        ),\n        Handler(\n            callback=handlers.handle_get_last_name,\n            state=bot_states.RegisterState.last_name,\n        ),\n        Handler(callback=handlers.handle_get_age, state=bot_states.RegisterState.age),\n    ]\n\n\ndef get_show_data_handlers():\n    return [\n        Handler(callback=handlers.handle_show_data, commands=[\"show_data\"]),\n    ]\n\n\ndef get_delete_account_handlers():\n    return [\n        Handler(callback=handlers.handle_delete_account, commands=[\"delete_account\"]),\n        Handler(\n            callback=handlers.handle_finish_delete_account,\n            state=bot_states.DeleteAccountState.are_you_sure,\n        ),\n    ]\n\n\ndef create_bot(bot_token, pool):\n    state_storage = bot_states.StateYDBStorage(pool)\n    bot = TeleBot(bot_token, state_storage=state_storage)\n\n    handlers = []\n    handlers.extend(get_start_handlers())\n    handlers.extend(get_registration_handlers())\n    handlers.extend(get_show_data_handlers())\n    handlers.extend(get_delete_account_handlers())\n\n    for handler in handlers:\n        bot.register_message_handler(\n            partial(handler.callback, pool=pool), **handler.kwargs, pass_bot=True\n        )\n\n    bot.add_custom_filter(custom_filters.StateFilter(bot))\n    return bot\n"
  },
  {
    "path": "create_function_version.sh",
    "content": "export function_id=\"<YOUR FUNCTION ID>\"\nexport service_account_id=\"<YOUR SERVICE ACCOUNT ID>\"\nexport ydb_database=\"<YOUR YDB DATABASE CONNECTION>\"\nexport ydb_endpoint=\"<YOUR YDB ENDPOINT>\"\nexport bot_token=\"<YOUR BOT TOKEN>\"\n\nzip code *.py *.md *.txt database/* user_interaction/* bot/* tests/*.py &&\nyc serverless function version create \\\n    --function-id=\"$function_id\" \\\n    --runtime python311 \\\n    --entrypoint index.handler \\\n    --memory 128m \\\n    --execution-timeout 40s \\\n    --source-path code.zip \\\n    --service-account-id=\"$service_account_id\" \\\n    --environment YDB_DATABASE=\"$ydb_database\" \\\n    --environment YDB_ENDPOINT=\"$ydb_endpoint\" \\\n    --environment BOT_TOKEN=\"$bot_token\"\n"
  },
  {
    "path": "database/__init__.py",
    "content": ""
  },
  {
    "path": "database/model.py",
    "content": "import json\n\nfrom database import queries\nfrom database.utils import execute_select_query, execute_update_query\n\n\ndef get_state(pool, user_id):\n    results = execute_select_query(pool, queries.get_user_state, user_id=user_id)\n    if len(results) == 0:\n        return None\n    if results[0][\"state\"] is None:\n        return None\n    return json.loads(results[0][\"state\"])\n\n\ndef set_state(pool, user_id, state):\n    execute_update_query(\n        pool, queries.set_user_state, user_id=user_id, state=json.dumps(state)\n    )\n\n\ndef clear_state(pool, user_id):\n    execute_update_query(pool, queries.set_user_state, user_id=user_id, state=None)\n\n\ndef add_user_info(pool, user_id, first_name, last_name, age):\n    execute_update_query(\n        pool,\n        queries.add_user_info,\n        user_id=user_id,\n        first_name=first_name,\n        last_name=last_name,\n        age=age,\n    )\n\n\ndef get_user_info(pool, user_id):\n    result = execute_select_query(pool, queries.get_user_info, user_id=user_id)\n\n    if len(result) != 1:\n        return None\n    return result[0]\n\n\ndef delete_user_info(pool, user_id):\n    execute_update_query(pool, queries.delete_user_info, user_id=user_id)\n"
  },
  {
    "path": "database/queries.py",
    "content": "USERS_INFO_TABLE_PATH = \"user_personal_info\"\nSTATES_TABLE_PATH = \"states\"\n\n\nget_user_state = f\"\"\"\n    DECLARE $user_id AS Uint64;\n\n    SELECT state\n    FROM `{STATES_TABLE_PATH}`\n    WHERE user_id == $user_id;\n\"\"\"\n\nset_user_state = f\"\"\"\n    DECLARE $user_id AS Uint64;\n    DECLARE $state AS Utf8?;\n\n    UPSERT INTO `{STATES_TABLE_PATH}` (`user_id`, `state`)\n    VALUES ($user_id, $state);\n\"\"\"\n\nget_user_info = f\"\"\"\n    DECLARE $user_id AS Int64;\n    \n    SELECT\n        user_id,\n        age,\n        first_name,\n        last_name\n    FROM `{USERS_INFO_TABLE_PATH}`\n    WHERE user_id == $user_id;\n\"\"\"\n\nadd_user_info = f\"\"\"\n    DECLARE $user_id AS Uint64;\n    DECLARE $first_name AS Utf8;\n    DECLARE $last_name AS Utf8;\n    DECLARE $age AS Uint64;\n\n    INSERT INTO `{USERS_INFO_TABLE_PATH}` (user_id, first_name, last_name, age)\n    VALUES ($user_id, $first_name, $last_name, $age);\n\"\"\"\n\ndelete_user_info = f\"\"\"\n    DECLARE $user_id AS Uint64;\n\n    DELETE FROM `{USERS_INFO_TABLE_PATH}`\n    WHERE user_id == $user_id;\n\n    DELETE FROM `{STATES_TABLE_PATH}`\n    WHERE user_id == $user_id;\n\"\"\"\n"
  },
  {
    "path": "database/utils.py",
    "content": "import ydb\n\n\ndef _format_kwargs(kwargs):\n    return {\"${}\".format(key): value for key, value in kwargs.items()}\n\n\n# using prepared statements\n# https://ydb.tech/en/docs/reference/ydb-sdk/example/python/#param-prepared-queries\ndef execute_update_query(pool, query, **kwargs):\n    def callee(session):\n        prepared_query = session.prepare(query)\n        session.transaction(ydb.SerializableReadWrite()).execute(\n            prepared_query, _format_kwargs(kwargs), commit_tx=True\n        )\n\n    return pool.retry_operation_sync(callee)\n\n\n# using prepared statements\n# https://ydb.tech/en/docs/reference/ydb-sdk/example/python/#param-prepared-queries\ndef execute_select_query(pool, query, **kwargs):\n    def callee(session):\n        prepared_query = session.prepare(query)\n        result_sets = session.transaction(ydb.SerializableReadWrite()).execute(\n            prepared_query, _format_kwargs(kwargs), commit_tx=True\n        )\n        return result_sets[0].rows\n\n    return pool.retry_operation_sync(callee)\n"
  },
  {
    "path": "database/ydb_settings.py",
    "content": "import ydb\n\n\ndef get_ydb_pool(ydb_endpoint, ydb_database, timeout=30):\n    ydb_driver_config = ydb.DriverConfig(\n        ydb_endpoint,\n        ydb_database,\n        credentials=ydb.credentials_from_env_variables(),\n        root_certificates=ydb.load_ydb_root_certificate(),\n    )\n\n    ydb_driver = ydb.Driver(ydb_driver_config)\n    ydb_driver.wait(fail_fast=True, timeout=timeout)\n    return ydb.SessionPool(ydb_driver)\n"
  },
  {
    "path": "index.py",
    "content": "import os\n\nimport telebot\n\nfrom bot.structure import create_bot\nfrom database.ydb_settings import get_ydb_pool\nfrom logs import logger\n\nYDB_ENDPOINT = os.getenv(\"YDB_ENDPOINT\")\nYDB_DATABASE = os.getenv(\"YDB_DATABASE\")\nBOT_TOKEN = os.getenv(\"BOT_TOKEN\")\n\n\ndef handler(event, _):\n    logger.debug(f\"New event: {event}\")\n\n    pool = get_ydb_pool(YDB_ENDPOINT, YDB_DATABASE)\n    bot = create_bot(BOT_TOKEN, pool)\n\n    message = telebot.types.Update.de_json(event[\"body\"])\n    bot.process_new_updates([message])\n    return {\n        \"statusCode\": 200,\n        \"body\": \"!\",\n    }\n"
  },
  {
    "path": "logs.py",
    "content": "import logging\nimport traceback\n\nfrom pythonjsonlogger import jsonlogger\nfrom telebot.types import Message\n\n\n# https://cloud.yandex.com/en/docs/functions/operations/function/logs-write#function-examples\nclass YcLoggingFormatter(jsonlogger.JsonFormatter):\n    def add_fields(self, log_record, record, message_dict):\n        super(YcLoggingFormatter, self).add_fields(log_record, record, message_dict)\n        log_record[\"logger\"] = record.name\n        log_record[\"level\"] = str.replace(\n            str.replace(record.levelname, \"WARNING\", \"WARN\"), \"CRITICAL\", \"FATAL\"\n        )\n\n\nlogHandler = logging.StreamHandler()\nlogHandler.setFormatter(YcLoggingFormatter(\"%(message)s %(level)s %(logger)s\"))\n\nlogger = logging.getLogger(\"logger\")\nlogger.addHandler(logHandler)\nlogger.setLevel(logging.DEBUG)\n\n\ndef find_in_args(args, target_type):\n    for arg in args:\n        if isinstance(arg, target_type):\n            return arg\n\n\ndef find_in_kwargs(kwargs, target_type):\n    return find_in_args(kwargs.values(), target_type)\n\n\ndef get_message_info(*args, **kwargs):\n    message_args = find_in_args(args, Message)\n    if message_args is not None:\n        return message_args.chat.id, message_args.text\n\n    message_kwargs = find_in_kwargs(kwargs, Message)\n    if message_kwargs is not None:\n        return message_kwargs.chat.id, message_kwargs.text\n\n    return \"UNKNOWN\", \"UNKNOWN\"\n\n\ndef logged_execution(func):\n    def wrapper(*args, **kwargs):\n        chat_id, text = get_message_info(*args, **kwargs)\n\n        logger.info(\n            f\"[LOG] Starting {func.__name__} - chat_id {chat_id}\",\n            extra={\n                \"text\": text,\n                \"arg\": str(args),\n                \"kwarg\": str(kwargs),\n            },\n        )\n        try:\n            func(*args, **kwargs)\n            logger.info(\n                f\"[LOG] Finished {func.__name__} - chat_id {chat_id}\",\n                extra={\n                    \"text\": text,\n                    \"arg\": str(args),\n                    \"kwarg\": str(kwargs),\n                },\n            )\n        except Exception as e:\n            logger.error(\n                f\"[LOG] Failed {func.__name__} - chat_id {chat_id} - exception {e}\",\n                extra={\n                    \"text\": text,\n                    \"arg\": str(args),\n                    \"kwarg\": str(kwargs),\n                    \"error\": e,\n                    \"traceback\": traceback.format_exc(),\n                },\n            )\n\n    return wrapper\n"
  },
  {
    "path": "requirements.txt",
    "content": "pyTelegramBotAPI==4.6.0\nydb==3.3.1\npython-json-logger==2.0.7\nprotobuf==3.20.0\n"
  },
  {
    "path": "tests/fixtures.py",
    "content": "import os\n\nimport pytest\nfrom pyrogram import Client\n\napi_id = os.environ.get(\"TELEGRAM_API_ID\")\napi_hash = os.environ.get(\"TELEGRAM_API_HASH\")\nclient_name = \"languagecardsbottester\"\nworkdir = \"/Users/mariakozlova/ml_and_staff/language_cards_bot/tests\"\n\n\n@pytest.fixture\ndef test_client():\n    client = Client(client_name, api_id, api_hash, workdir=workdir)\n    client.start()\n    yield client\n    client.stop()\n\n\n@pytest.fixture\ndef chat_id():\n    return \"@language_cards_tester_bot\"\n"
  },
  {
    "path": "tests/utils.py",
    "content": "from time import sleep\n\n\nclass CommandContext:\n    def __init__(self, client, chat_id, command):\n        self.client = client\n        self.chat_id = chat_id\n        self.command = command\n        self.step = 1\n\n    def __enter__(self):\n        self.message = self.client.send_message(chat_id=self.chat_id, text=self.command)\n        return self\n\n    def __exit__(self, exc_type, exc_val, exc_tb):\n        return\n\n    def expect_next(self, correct_response, sleep_s=0.2, timeout_s=60):\n        assert correct_response is not None, \"correct_response should be specified\"\n\n        timer = 0\n        while timer <= timeout_s:\n            response = self.client.get_messages(\n                self.chat_id, self.message.id + self.step\n            ).text\n            if response is not None:  # found target message\n                self.step += 1\n                assert response == correct_response, (\n                    f\"'{self.command}' failed due to wrong reaction on step {self.step - 1}\"\n                    f\"\\nreaction: {response}\\nexpected: {correct_response}\"\n                )\n                return\n\n            timer += sleep_s\n            sleep(sleep_s)\n\n    def expect_next_prefix(self, correct_response_prefix, sleep_s=0.2, timeout_s=60):\n        assert (\n            correct_response_prefix is not None\n        ), \"correct_response should be specified\"\n\n        timer = 0\n        while timer <= timeout_s:\n            response = self.client.get_messages(\n                self.chat_id, self.message.id + self.step\n            ).text\n            if response is not None:  # found target message\n                self.step += 1\n                assert response.startswith(correct_response_prefix), (\n                    f\"'{self.command}' failed due to wrong reaction prefix on step {self.step - 1}\"\n                    f\"\\nreaction: {response}\\nexpected: {correct_response_prefix}\"\n                )\n                return\n\n            timer += sleep_s\n            sleep(sleep_s)\n\n    def expect_none(self, sleep_s=0.5, timeout_s=2):\n        timer = 0\n        while timer <= timeout_s:\n            response = self.client.get_messages(\n                self.chat_id, self.message.id + self.step\n            ).text\n            assert response is None, (\n                f\"'{self.command}' failed due to presence of reaction on step {self.step}\"\n                f\"\\nreaction: {response}\"\n            )\n\n            timer += sleep_s\n            sleep(sleep_s)\n\n    def expect_length(self, num_rows, sleep_s=0.5, timeout_s=60):\n        raise NotImplementedError\n\n    def expect_any(self, sleep_s=0.2, timeout_s=60):\n        timer = 0\n        while timer <= timeout_s:\n            response = self.client.get_messages(\n                self.chat_id, self.message.id + self.step\n            ).text\n            if response is not None:  # found target message\n                self.step += 1\n                break\n\n            timer += sleep_s\n            sleep(sleep_s)\n\n        assert (\n            response is not None\n        ), f\"'{self.command}' failed due to absence of reaction on step {self.step - 1}\"\n\n    def expect_any_multiple(self, number_of_responses, sleep_s=0.2, timeout_s=60):\n        for i in range(number_of_responses):\n            self.expect_any(sleep_s=sleep_s, timeout_s=timeout_s)\n\n    def expect_next_number_of_rows(self, n_rows, sleep_s=0.2, timeout_s=60):\n        timer = 0\n        while timer <= timeout_s:\n            response = self.client.get_messages(\n                self.chat_id, self.message.id + self.step\n            ).text\n            if response is not None:  # found target message\n                self.step += 1\n                assert len(response.split(\"\\n\")) == n_rows, (\n                    f\"'{self.command}' failed due to wrong number of rows on step {self.step - 1}\"\n                    f\"\\nreaction: {response}\\nexpected number of rows: {n_rows}\"\n                )\n                return\n\n            timer += sleep_s\n            sleep(sleep_s)\n"
  },
  {
    "path": "user_interaction/texts.py",
    "content": "START = (\n    \"Hello! This is a simple bot that can store your name and age, \"\n    \"show them back to you and delete them if requested.\\n\\n\"\n    \"List of commands:\\n\"\n    \"/start\\n\"\n    \"/register\\n\"\n    \"/show_data\\n\"\n    \"/delete_account\"\n)\n\nFIRST_NAME = \"Enter your first name.\"\nLAST_NAME = \"Enter your last name.\"\nAGE = \"Enter your age.\"\nAGE_IS_NOT_NUMBER = \"Age should be a positive number, try again.\"\n\nSHOW_DATA = \"First name: {}\\nLast name: {}\\nAge: {}\"\n\nDATA_IS_SAVED = \"Your data is saved!\\n\" + SHOW_DATA\nALREADY_REGISTERED = \"You are already registered!\\n\" + SHOW_DATA\nSHOW_DATA_WITH_PREFIX = \"Your data:\\n\" + SHOW_DATA\n\nNOT_REGISTERED = \"You are not registered yet, try /register.\"\n\nCANCEL_REGISTER = \"Cancelled! Your data is not saved.\"\n\nDELETE_ACCOUNT = \"Are you sure you want to delete your account?\"\nDELETE_ACCOUNT_OPTIONS = {\"Yes!\": True, \"No..\": False}\nDELETE_ACCOUNT_UNKNOWN = \"I don't understand this command.\"\nDELETE_ACCOUNT_DONE = \"Done! You can /register again.\"\nDELETE_ACCOUNT_CANCEL = \"Ok, stay for longer!\"\n"
  }
]