main 8e62724b564e cached
20 files
36.1 KB
9.0k tokens
59 symbols
1 requests
Download .txt
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. <details><summary>Screenshot</summary>
![Yandex Console Screenshot](screenshots/01-create-folder.png?raw=true "Title")</details>
4) 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>
![Yandex Console Screenshot](screenshots/04-create-service-account.png?raw=true "Title")</details>
5) Create an API gateway with any name and the default specification. <details><summary>Screenshot</summary>
![Yandex Console Screenshot](screenshots/06-create-api-gateway.png?raw=true "Title")</details>
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. <details><summary>Screenshots</summary>Create 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")</details>
7) Copy your function ID and save for the next step. <details><summary>Screenshot</summary>
![Yandex Console Screenshot](screenshots/10-copy-function-id.png?raw=true "Title")</details>
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 `<function ID>` 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: <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. <details><summary>Screenshot</summary>
<img src="screenshots/05-create-telegram-bot.png" width="400"></details>
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. <details><summary>Commands</summary><pre>
  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
  </pre><img src="screenshots/05-1_bot_commands.png" width="400">
</details>

3) 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"}`.
- <details><summary>Request</summary>

  ```
  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"}'
  ```

</details>

- <details><summary>Request for Windows</summary>

  ```
  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\"}"
  ```

</details>
</br>

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.
<details><summary>Successful API gateway logs</summary>

![Yandex Console Screenshot](screenshots/12-api-gateway-logs.png?raw=true "Title")
</details>
<details><summary>Successful function logs</summary>

![Yandex Console Screenshot](screenshots/13-function-logs.png?raw=true "Title")
</details>
</br>
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. <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")
</details>

2) 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>
![Yandex Console Screenshot](screenshots/19-create-ydb-tables.png?raw=true "Title")
</details>

- <details><summary>SQL script</summary>

  ```
  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`)
  );
  ```

</details>

</br>


## 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`. <details><summary>How to choose their values</summary>
      - `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.</details> <details><summary>How it should look like in GUI - screenshot.</summary>
      ![Yandex Console Screenshot](screenshots/16-create-function-version-gui.png?raw=true "Title")
      </details>
3) Click `Create version` and wait for it to be created.

</br>
<b>Alternatively</b>, you can use command line interface to do that.
<details><summary>Create Function version using CLI</summary>

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.
</details>

</br>
<b>Awesome! Now try your bot!</b>

## What next?
1) 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">
</details>

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.<details><summary>Function logs - screenshot</summary><img src="screenshots/22-function-logs.png" width="800">
</details>

3. 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">
</details>

# 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="<YOUR FUNCTION ID>"
export service_account_id="<YOUR SERVICE ACCOUNT ID>"
export ydb_database="<YOUR YDB DATABASE CONNECTION>"
export ydb_endpoint="<YOUR YDB ENDPOINT>"
export bot_token="<YOUR 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!"
Download .txt
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
Download .txt
SYMBOL INDEX (59 symbols across 11 files)

FILE: bot/handlers.py
  function handle_start (line 8) | def handle_start(message, bot, pool):
  function handle_register (line 13) | def handle_register(message, bot, pool):
  function handle_cancel_registration (line 39) | def handle_cancel_registration(message, bot, pool):
  function handle_get_first_name (line 49) | def handle_get_first_name(message, bot, pool):
  function handle_get_last_name (line 61) | def handle_get_last_name(message, bot, pool):
  function handle_get_age (line 73) | def handle_get_age(message, bot, pool):
  function handle_show_data (line 98) | def handle_show_data(message, bot, pool):
  function handle_delete_account (line 117) | def handle_delete_account(message, bot, pool):
  function handle_finish_delete_account (line 136) | def handle_finish_delete_account(message, bot, pool):

FILE: bot/keyboards.py
  function get_reply_keyboard (line 6) | def get_reply_keyboard(options, additional=None, **kwargs):

FILE: bot/states.py
  class StateYDBStorage (line 9) | class StateYDBStorage(StateStorageBase):
    method __init__ (line 14) | def __init__(self, ydb_pool):
    method set_data (line 18) | def set_data(self, chat_id, user_id, key, value):
    method get_data (line 31) | def get_data(self, chat_id, user_id):
    method set_state (line 41) | def set_state(self, chat_id, user_id, state):
    method delete_state (line 50) | def delete_state(self, chat_id, user_id):
    method reset_data (line 60) | def reset_data(self, chat_id, user_id):
    method get_state (line 71) | def get_state(self, chat_id, user_id):
    method get_interactive_data (line 77) | def get_interactive_data(self, chat_id, user_id):
    method save (line 80) | def save(self, chat_id, user_id, data):
  class RegisterState (line 88) | class RegisterState(StatesGroup):
  class DeleteAccountState (line 94) | class DeleteAccountState(StatesGroup):

FILE: bot/structure.py
  class Handler (line 11) | class Handler:
    method __init__ (line 12) | def __init__(self, callback, **kwargs):
  function get_start_handlers (line 17) | def get_start_handlers():
  function get_registration_handlers (line 23) | def get_registration_handlers():
  function get_show_data_handlers (line 47) | def get_show_data_handlers():
  function get_delete_account_handlers (line 53) | def get_delete_account_handlers():
  function create_bot (line 63) | def create_bot(bot_token, pool):

FILE: database/model.py
  function get_state (line 7) | def get_state(pool, user_id):
  function set_state (line 16) | def set_state(pool, user_id, state):
  function clear_state (line 22) | def clear_state(pool, user_id):
  function add_user_info (line 26) | def add_user_info(pool, user_id, first_name, last_name, age):
  function get_user_info (line 37) | def get_user_info(pool, user_id):
  function delete_user_info (line 45) | def delete_user_info(pool, user_id):

FILE: database/utils.py
  function _format_kwargs (line 4) | def _format_kwargs(kwargs):
  function execute_update_query (line 10) | def execute_update_query(pool, query, **kwargs):
  function execute_select_query (line 22) | def execute_select_query(pool, query, **kwargs):

FILE: database/ydb_settings.py
  function get_ydb_pool (line 4) | def get_ydb_pool(ydb_endpoint, ydb_database, timeout=30):

FILE: index.py
  function handler (line 14) | def handler(event, _):

FILE: logs.py
  class YcLoggingFormatter (line 9) | class YcLoggingFormatter(jsonlogger.JsonFormatter):
    method add_fields (line 10) | def add_fields(self, log_record, record, message_dict):
  function find_in_args (line 26) | def find_in_args(args, target_type):
  function find_in_kwargs (line 32) | def find_in_kwargs(kwargs, target_type):
  function get_message_info (line 36) | def get_message_info(*args, **kwargs):
  function logged_execution (line 48) | def logged_execution(func):

FILE: tests/fixtures.py
  function test_client (line 13) | def test_client():
  function chat_id (line 21) | def chat_id():

FILE: tests/utils.py
  class CommandContext (line 4) | class CommandContext:
    method __init__ (line 5) | def __init__(self, client, chat_id, command):
    method __enter__ (line 11) | def __enter__(self):
    method __exit__ (line 15) | def __exit__(self, exc_type, exc_val, exc_tb):
    method expect_next (line 18) | def expect_next(self, correct_response, sleep_s=0.2, timeout_s=60):
    method expect_next_prefix (line 37) | def expect_next_prefix(self, correct_response_prefix, sleep_s=0.2, tim...
    method expect_none (line 58) | def expect_none(self, sleep_s=0.5, timeout_s=2):
    method expect_length (line 72) | def expect_length(self, num_rows, sleep_s=0.5, timeout_s=60):
    method expect_any (line 75) | def expect_any(self, sleep_s=0.2, timeout_s=60):
    method expect_any_multiple (line 92) | def expect_any_multiple(self, number_of_responses, sleep_s=0.2, timeou...
    method expect_next_number_of_rows (line 96) | def expect_next_number_of_rows(self, n_rows, sleep_s=0.2, timeout_s=60):
Condensed preview — 20 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (39K chars).
[
  {
    "path": ".gitignore",
    "chars": 3140,
    "preview": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packagi"
  },
  {
    "path": "LICENSE",
    "chars": 1066,
    "preview": "MIT License\n\nCopyright (c) 2023 mskozlova\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\n"
  },
  {
    "path": "README.md",
    "chars": 9544,
    "preview": "# About\nThis is a simple example of a telegram bot implementation. This code is designed to be run on [Yandex Cloud Serv"
  },
  {
    "path": "bot/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "bot/handlers.py",
    "chars": 4664,
    "preview": "from bot import keyboards, states\nfrom database import model as db_model\nfrom logs import logged_execution\nfrom user_int"
  },
  {
    "path": "bot/keyboards.py",
    "chars": 460,
    "preview": "from telebot import types\n\nEMPTY = types.ReplyKeyboardRemove()\n\n\ndef get_reply_keyboard(options, additional=None, **kwar"
  },
  {
    "path": "bot/states.py",
    "chars": 2839,
    "preview": "from telebot.handler_backends import State, StatesGroup\nfrom telebot.storage.base_storage import StateContext, StateStor"
  },
  {
    "path": "bot/structure.py",
    "chars": 2231,
    "preview": "from functools import partial\n\nfrom telebot import TeleBot, custom_filters\n\nfrom bot import handlers as handlers\nfrom bo"
  },
  {
    "path": "create_function_version.sh",
    "chars": 702,
    "preview": "export function_id=\"<YOUR FUNCTION ID>\"\nexport service_account_id=\"<YOUR SERVICE ACCOUNT ID>\"\nexport ydb_database=\"<YOUR"
  },
  {
    "path": "database/__init__.py",
    "chars": 0,
    "preview": ""
  },
  {
    "path": "database/model.py",
    "chars": 1178,
    "preview": "import json\n\nfrom database import queries\nfrom database.utils import execute_select_query, execute_update_query\n\n\ndef ge"
  },
  {
    "path": "database/queries.py",
    "chars": 1091,
    "preview": "USERS_INFO_TABLE_PATH = \"user_personal_info\"\nSTATES_TABLE_PATH = \"states\"\n\n\nget_user_state = f\"\"\"\n    DECLARE $user_id A"
  },
  {
    "path": "database/utils.py",
    "chars": 1011,
    "preview": "import ydb\n\n\ndef _format_kwargs(kwargs):\n    return {\"${}\".format(key): value for key, value in kwargs.items()}\n\n\n# usin"
  },
  {
    "path": "database/ydb_settings.py",
    "chars": 420,
    "preview": "import ydb\n\n\ndef get_ydb_pool(ydb_endpoint, ydb_database, timeout=30):\n    ydb_driver_config = ydb.DriverConfig(\n       "
  },
  {
    "path": "index.py",
    "chars": 574,
    "preview": "import os\n\nimport telebot\n\nfrom bot.structure import create_bot\nfrom database.ydb_settings import get_ydb_pool\nfrom logs"
  },
  {
    "path": "logs.py",
    "chars": 2468,
    "preview": "import logging\nimport traceback\n\nfrom pythonjsonlogger import jsonlogger\nfrom telebot.types import Message\n\n\n# https://c"
  },
  {
    "path": "requirements.txt",
    "chars": 78,
    "preview": "pyTelegramBotAPI==4.6.0\nydb==3.3.1\npython-json-logger==2.0.7\nprotobuf==3.20.0\n"
  },
  {
    "path": "tests/fixtures.py",
    "chars": 485,
    "preview": "import os\n\nimport pytest\nfrom pyrogram import Client\n\napi_id = os.environ.get(\"TELEGRAM_API_ID\")\napi_hash = os.environ.g"
  },
  {
    "path": "tests/utils.py",
    "chars": 3989,
    "preview": "from time import sleep\n\n\nclass CommandContext:\n    def __init__(self, client, chat_id, command):\n        self.client = c"
  },
  {
    "path": "user_interaction/texts.py",
    "chars": 1033,
    "preview": "START = (\n    \"Hello! This is a simple bot that can store your name and age, \"\n    \"show them back to you and delete the"
  }
]

About this extraction

This page contains the full source code of the mskozlova/ydb_serverless_telegram_bot GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 20 files (36.1 KB), approximately 9.0k tokens, and a symbol index with 59 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!