├── bot ├── __init__.py ├── constants.py ├── keyboards.py ├── utils.py ├── states.py ├── structure.py └── handlers.py ├── database ├── __init__.py ├── ydb_settings.py ├── utils.py ├── model.py └── queries.py ├── user_interaction ├── config.py ├── options.py └── texts.py ├── screenshots ├── 20-bot_start.png ├── 01-create-folder.png ├── 02-name-folder.png ├── 13-function-logs.png ├── 14-ydb-settings.png ├── 21-bot_register.png ├── 22-function-logs.png ├── 05-1_bot_commands.png ├── 08-create-function.png ├── 06-create-api-gateway.png ├── 07-name-api-gateway.png ├── 10-copy-function-id.png ├── 12-api-gateway-logs.png ├── 15-set-bot-commands.png ├── 18-save-ydb-settings.png ├── 19-create-ydb-tables.png ├── 23-ydb-after-register.png ├── 03-newly-created-folder.png ├── 05-create-telegram-bot.png ├── 08-1-select-environment.png ├── 08-make-function-public.png ├── 14-create-ydb-database.png ├── 17-create-ydb-database.png ├── 04-create-service-account.png ├── 16-create-function-version-gui.png ├── 09-create-default-function-version.png └── 11-connect-api-gateway-to-fucntion.png ├── requirements.txt ├── tests ├── fixtures.py ├── handlers.py ├── test_help.py ├── test_delete_words.py ├── test_create_group.py ├── test_show_current_language.py ├── test_show_languages.py ├── test_delete_group.py ├── test_show_words.py ├── utils.py ├── test_set_language.py ├── test_add_words.py ├── test_group_delete_words.py ├── test_show_groups.py └── test_group_add_words.py ├── index.py ├── LICENSE ├── logs.py ├── word.py └── README.md /bot/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /database/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /user_interaction/config.py: -------------------------------------------------------------------------------- 1 | TRAIN_MAX_N_WORDS = 9999 2 | -------------------------------------------------------------------------------- /bot/constants.py: -------------------------------------------------------------------------------- 1 | SHOW_WORDS_BATCH_SIZE = 20 2 | GROUP_ADD_WORDS_BATCH_SIZE = 6 3 | -------------------------------------------------------------------------------- /screenshots/20-bot_start.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/20-bot_start.png -------------------------------------------------------------------------------- /screenshots/01-create-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/01-create-folder.png -------------------------------------------------------------------------------- /screenshots/02-name-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/02-name-folder.png -------------------------------------------------------------------------------- /screenshots/13-function-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/13-function-logs.png -------------------------------------------------------------------------------- /screenshots/14-ydb-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/14-ydb-settings.png -------------------------------------------------------------------------------- /screenshots/21-bot_register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/21-bot_register.png -------------------------------------------------------------------------------- /screenshots/22-function-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/22-function-logs.png -------------------------------------------------------------------------------- /screenshots/05-1_bot_commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/05-1_bot_commands.png -------------------------------------------------------------------------------- /screenshots/08-create-function.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/08-create-function.png -------------------------------------------------------------------------------- /screenshots/06-create-api-gateway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/06-create-api-gateway.png -------------------------------------------------------------------------------- /screenshots/07-name-api-gateway.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/07-name-api-gateway.png -------------------------------------------------------------------------------- /screenshots/10-copy-function-id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/10-copy-function-id.png -------------------------------------------------------------------------------- /screenshots/12-api-gateway-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/12-api-gateway-logs.png -------------------------------------------------------------------------------- /screenshots/15-set-bot-commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/15-set-bot-commands.png -------------------------------------------------------------------------------- /screenshots/18-save-ydb-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/18-save-ydb-settings.png -------------------------------------------------------------------------------- /screenshots/19-create-ydb-tables.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/19-create-ydb-tables.png -------------------------------------------------------------------------------- /screenshots/23-ydb-after-register.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/23-ydb-after-register.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pyTelegramBotAPI==4.6.0 2 | ydb==3.3.1 3 | protobuf==3.19.0 4 | six==1.16.0 5 | python-json-logger==2.0.7 6 | emojis==0.7.0 -------------------------------------------------------------------------------- /screenshots/03-newly-created-folder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/03-newly-created-folder.png -------------------------------------------------------------------------------- /screenshots/05-create-telegram-bot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/05-create-telegram-bot.png -------------------------------------------------------------------------------- /screenshots/08-1-select-environment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/08-1-select-environment.png -------------------------------------------------------------------------------- /screenshots/08-make-function-public.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/08-make-function-public.png -------------------------------------------------------------------------------- /screenshots/14-create-ydb-database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/14-create-ydb-database.png -------------------------------------------------------------------------------- /screenshots/17-create-ydb-database.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/17-create-ydb-database.png -------------------------------------------------------------------------------- /screenshots/04-create-service-account.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/04-create-service-account.png -------------------------------------------------------------------------------- /screenshots/16-create-function-version-gui.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/16-create-function-version-gui.png -------------------------------------------------------------------------------- /screenshots/09-create-default-function-version.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/09-create-default-function-version.png -------------------------------------------------------------------------------- /screenshots/11-connect-api-gateway-to-fucntion.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mskozlova/language_cards_bot/HEAD/screenshots/11-connect-api-gateway-to-fucntion.png -------------------------------------------------------------------------------- /database/ydb_settings.py: -------------------------------------------------------------------------------- 1 | import ydb 2 | 3 | 4 | def get_ydb_pool(ydb_endpoint, ydb_database, timeout=30): 5 | ydb_driver_config = ydb.DriverConfig( 6 | ydb_endpoint, 7 | ydb_database, 8 | credentials=ydb.credentials_from_env_variables(), 9 | root_certificates=ydb.load_ydb_root_certificate(), 10 | ) 11 | 12 | ydb_driver = ydb.Driver(ydb_driver_config) 13 | ydb_driver.wait(fail_fast=True, timeout=timeout) 14 | return ydb.SessionPool(ydb_driver) 15 | -------------------------------------------------------------------------------- /tests/fixtures.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | from pyrogram import Client 5 | 6 | api_id = os.environ.get("TELEGRAM_API_ID") 7 | api_hash = os.environ.get("TELEGRAM_API_HASH") 8 | client_name = "languagecardsbottester" 9 | workdir = "/Users/mariakozlova/ml_and_staff/language_cards_bot/tests" 10 | 11 | 12 | @pytest.fixture 13 | def test_client(): 14 | client = Client(client_name, api_id, api_hash, workdir=workdir) 15 | client.start() 16 | yield client 17 | client.stop() 18 | 19 | 20 | @pytest.fixture 21 | def chat_id(): 22 | return "@language_cards_tester_bot" 23 | -------------------------------------------------------------------------------- /tests/handlers.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from time import sleep 3 | 4 | from user_interaction import texts 5 | 6 | sys.path.append("../") 7 | 8 | import database.model as db_model 9 | from logs import logged_execution 10 | 11 | 12 | @logged_execution 13 | def handle_clear_db(message, bot, pool): 14 | db_model.truncate_tables(pool) 15 | bot.send_message(message.chat.id, "Done!") 16 | 17 | 18 | @logged_execution 19 | def handle_stop(message, bot, pool): 20 | sleep(2) 21 | bot.delete_state(message.from_user.id, message.chat.id) 22 | bot.send_message(message.chat.id, texts.stop_message) 23 | -------------------------------------------------------------------------------- /index.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import telebot 4 | 5 | from bot.structure import create_bot 6 | from database.ydb_settings import get_ydb_pool 7 | from logs import logger 8 | 9 | YDB_ENDPOINT = os.getenv("YDB_ENDPOINT") 10 | YDB_DATABASE = os.getenv("YDB_DATABASE") 11 | BOT_TOKEN = os.getenv("BOT_TOKEN") 12 | 13 | 14 | def handler(event, _): 15 | logger.debug(f"New event: {event}") 16 | 17 | pool = get_ydb_pool(YDB_ENDPOINT, YDB_DATABASE) 18 | bot = create_bot(BOT_TOKEN, pool) 19 | 20 | message = telebot.types.Update.de_json(event["body"]) 21 | bot.process_new_updates([message]) 22 | return { 23 | "statusCode": 200, 24 | "body": "!", 25 | } 26 | -------------------------------------------------------------------------------- /user_interaction/options.py: -------------------------------------------------------------------------------- 1 | train_strategy_options = ["random", "new", "bad", "group"] 2 | 3 | train_direction_options = { 4 | "➡️ㅤ": "to", 5 | "⬅️ㅤ": "from", 6 | } # invisible symbols to avoid large emoji 7 | 8 | train_duration_options = ["10", "20", "All"] 9 | 10 | train_hints_options = ["flashcards", "test", "a****z", "no hints"] 11 | 12 | train_reactions = { 13 | 0.9: "🎉", 14 | 0.7: "👏", 15 | 0.4: "😐", 16 | 0.0: "😡", 17 | } 18 | 19 | show_words_sort_options = [ 20 | "a-z", 21 | "z-a", 22 | "score ⬇️", 23 | "score ⬆️", 24 | "n trains ⬇️", 25 | "n trains ⬆️", 26 | "time added ⬇️", 27 | "time added ⬆️", 28 | ] 29 | 30 | add_words_modes = ["one-by-one", "together"] 31 | 32 | group_add_words_sort_options = ["a-z", "time added ⬇️"] 33 | 34 | group_add_words_prefixes = { 35 | 0: "🖤", 36 | 1: "💚", 37 | } 38 | 39 | delete_are_you_sure = { 40 | "Yes!": True, 41 | "No..": False, 42 | } 43 | 44 | show_languages_mark_current = { 45 | True: "💚 {}", 46 | False: "🖤 {}", 47 | } 48 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 mskozlova 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /database/utils.py: -------------------------------------------------------------------------------- 1 | import ydb 2 | 3 | 4 | def format_kwargs(kwargs): 5 | return {"${}".format(key): value for key, value in kwargs.items()} 6 | 7 | 8 | # using prepared statements 9 | # https://ydb.tech/en/docs/reference/ydb-sdk/example/python/#param-prepared-queries 10 | def execute_update_query(pool, query, **kwargs): 11 | def callee(session): 12 | prepared_query = session.prepare(query) 13 | session.transaction(ydb.SerializableReadWrite()).execute( 14 | prepared_query, format_kwargs(kwargs), commit_tx=True 15 | ) 16 | 17 | return pool.retry_operation_sync(callee) 18 | 19 | 20 | # using prepared statements 21 | # https://ydb.tech/en/docs/reference/ydb-sdk/example/python/#param-prepared-queries 22 | def execute_select_query(pool, query, **kwargs): 23 | def callee(session): 24 | prepared_query = session.prepare(query) 25 | result_sets = session.transaction(ydb.SerializableReadWrite()).execute( 26 | prepared_query, format_kwargs(kwargs), commit_tx=True 27 | ) 28 | return result_sets[0].rows 29 | 30 | return pool.retry_operation_sync(callee) 31 | -------------------------------------------------------------------------------- /tests/test_help.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | 18 | def test_start(test_client, chat_id): 19 | with utils.CommandContext(test_client, chat_id, "/start") as command: 20 | command.expect_next_prefix("Ahoy, sexy!") 21 | 22 | 23 | def test_help(test_client, chat_id): 24 | with utils.CommandContext(test_client, chat_id, "/help") as command: 25 | command.expect_next_prefix("Ahoy, sexy!") 26 | 27 | def test_howto(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/howto") as command: 29 | command.expect_next_prefix("How to start learning?") 30 | 31 | def test_howto_training(test_client, chat_id): 32 | with utils.CommandContext(test_client, chat_id, "/howto_training") as command: 33 | command.expect_next_prefix("How to train") 34 | 35 | def test_howto_groups(test_client, chat_id): 36 | with utils.CommandContext(test_client, chat_id, "/howto_groups") as command: 37 | command.expect_next_prefix("How to create and manage groups") 38 | -------------------------------------------------------------------------------- /bot/keyboards.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from telebot import types 4 | 5 | import word as word_utils 6 | from user_interaction import options, texts 7 | 8 | empty = types.ReplyKeyboardRemove() 9 | 10 | 11 | def get_reply_keyboard(options, additional=None, **kwargs): 12 | if "row_width" in kwargs: 13 | row_width = kwargs["row_width"] 14 | else: 15 | row_width = len(options) 16 | 17 | markup = types.ReplyKeyboardMarkup( 18 | row_width=row_width, 19 | resize_keyboard=True, 20 | one_time_keyboard=True, 21 | ) 22 | markup.add(*options, row_width=row_width) 23 | if additional: 24 | markup.add(*additional, row_width=len(additional)) 25 | 26 | return markup 27 | 28 | 29 | def get_masked_choices(choices, mask, additional_commands=[], row_width=2): 30 | markup = types.ReplyKeyboardMarkup(row_width=row_width, resize_keyboard=True) 31 | 32 | formatted_choices = [ 33 | "{}{}".format( 34 | options.group_add_words_prefixes[mask], 35 | word_utils.format_word_for_group_action(entry), 36 | ) 37 | for entry, mask in zip(choices, mask) 38 | ] 39 | if len(formatted_choices) % row_width != 0: 40 | formatted_choices.extend( 41 | [""] * (row_width - len(formatted_choices) % row_width) 42 | ) 43 | 44 | markup.add(*formatted_choices, row_width=row_width) 45 | markup.add(*additional_commands, row_width=len(additional_commands)) 46 | return markup 47 | 48 | 49 | def format_train_buttons(translation, hints, hints_type): 50 | if hints_type == "flashcards": 51 | markup = types.ReplyKeyboardMarkup( 52 | row_width=1, one_time_keyboard=True, resize_keyboard=True 53 | ) 54 | markup.add(*["/next", "/stop"]) 55 | return markup 56 | 57 | if hints_type != "test": 58 | return empty 59 | 60 | all_words_list = hints + [ 61 | translation, 62 | ] 63 | random.shuffle(all_words_list) 64 | markup = types.ReplyKeyboardMarkup( 65 | row_width=2, one_time_keyboard=True, resize_keyboard=True 66 | ) 67 | markup.add(*[w.split("/")[0] for w in all_words_list]) 68 | return markup 69 | -------------------------------------------------------------------------------- /tests/test_delete_words.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | 27 | def test_add_words(test_client, chat_id): 28 | words = list(map(str, range(10))) 29 | 30 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 31 | command.expect_any() 32 | 33 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 34 | command.expect_any() 35 | 36 | with utils.CommandContext(test_client, chat_id, "\n".join(words)) as command: 37 | command.expect_next(texts.add_words_instruction_one_by_one_2.format(len(words))) 38 | command.expect_any() 39 | 40 | for word in words: 41 | with utils.CommandContext(test_client, chat_id, word) as command: 42 | command.expect_any() 43 | 44 | 45 | def test_delete_words(test_client, chat_id): 46 | words_to_delete = ["3", "5", "11"] 47 | 48 | with utils.CommandContext(test_client, chat_id, "/delete_words") as command: 49 | command.expect_next(texts.delete_words_start) 50 | 51 | with utils.CommandContext( 52 | test_client, chat_id, "\n".join(words_to_delete) 53 | ) as command: 54 | command.expect_next_prefix("Deleted {} word(s)".format(2)) 55 | 56 | with utils.CommandContext(test_client, chat_id, "/show_words") as command: 57 | command.expect_next_prefix("You have 8 word(s) for language") 58 | command.expect_next_prefix(texts.choose_sorting) 59 | 60 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 61 | command.expect_any() 62 | -------------------------------------------------------------------------------- /tests/test_create_group.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | 27 | def test_create_group(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 29 | command.expect_next(texts.create_group_name) 30 | 31 | with utils.CommandContext(test_client, chat_id, "abc") as command: 32 | command.expect_next(texts.group_created) 33 | 34 | 35 | def test_create_group_same_name(test_client, chat_id): 36 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 37 | command.expect_next(texts.create_group_name) 38 | 39 | with utils.CommandContext(test_client, chat_id, "abc") as command: 40 | command.expect_next(texts.group_already_exists) 41 | 42 | 43 | def test_create_group_cancel(test_client, chat_id): 44 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 45 | command.expect_next(texts.create_group_name) 46 | 47 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 48 | command.expect_next(texts.cancel_short) 49 | 50 | 51 | def test_create_group_invalid_name(test_client, chat_id): 52 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 53 | command.expect_next(texts.create_group_name) 54 | 55 | with utils.CommandContext(test_client, chat_id, ".123abc ") as command: 56 | command.expect_next(texts.group_name_invalid) 57 | 58 | with utils.CommandContext(test_client, chat_id, "abc_абв") as command: 59 | command.expect_next(texts.group_name_invalid) 60 | 61 | with utils.CommandContext(test_client, chat_id, " abc_123_ ") as command: 62 | command.expect_next(texts.group_created) 63 | -------------------------------------------------------------------------------- /bot/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import emojis 4 | 5 | import database.model as db_model 6 | from bot import keyboards 7 | from logs import logged_execution 8 | from user_interaction import texts 9 | 10 | 11 | @logged_execution 12 | def handle_language_not_set(message, bot): 13 | bot.send_message(message.chat.id, texts.no_language_is_set) 14 | 15 | 16 | @logged_execution 17 | def suggest_group_choices(message, bot, pool, next_state): 18 | language = db_model.get_current_language(pool, message.chat.id) 19 | if language is None: 20 | handle_language_not_set(message, bot) 21 | return 22 | 23 | groups = db_model.get_all_groups(pool, message.chat.id, language) 24 | group_names = sorted([group["group_name"].decode() for group in groups]) 25 | 26 | if len(groups) == 0: 27 | bot.reply_to(message, texts.no_groups_yet) 28 | return 29 | 30 | bot.set_state(message.from_user.id, next_state, message.chat.id) 31 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 32 | data["language"] = language 33 | data["group_names"] = group_names 34 | 35 | markup = keyboards.get_reply_keyboard(group_names, ["/exit"], row_width=3) 36 | 37 | return bot.send_message(message.chat.id, texts.group_choose, reply_markup=markup) 38 | 39 | 40 | @logged_execution 41 | def get_number_of_batches(batch_size, total_number): 42 | n_batches = total_number // batch_size 43 | if total_number % batch_size > 0: 44 | n_batches += 1 45 | return n_batches 46 | 47 | 48 | @logged_execution 49 | def check_language_name(name): 50 | return ( # emoji 51 | len(emojis.get(name)) == 1 52 | and emojis.db.get_emoji_by_alias(emojis.decode(name)[1:-1]) is not None 53 | and emojis.db.get_emoji_by_alias(emojis.decode(name)[1:-1]).category == "Flags" 54 | ) or re.fullmatch( # text 55 | "[a-z]+", name 56 | ) is not None 57 | 58 | 59 | @logged_execution 60 | def check_group_name(name): 61 | return re.fullmatch("[0-9a-z_]+", name) is not None 62 | 63 | 64 | @logged_execution 65 | def save_words_edit_to_group(pool, chat_id, language, group_id, words, action): 66 | if len(words) > 0: 67 | if action == "add": 68 | db_model.add_words_to_group(pool, chat_id, language, group_id, words) 69 | elif action == "delete": 70 | db_model.delete_words_from_group(pool, chat_id, language, group_id, words) 71 | 72 | return len(words) 73 | 74 | 75 | @logged_execution 76 | def clear_history(bot, chat_id, from_message_id, to_message_id): 77 | for message_id in range(from_message_id, to_message_id): 78 | bot.delete_message(chat_id, message_id) 79 | -------------------------------------------------------------------------------- /tests/test_show_current_language.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | 18 | def test_show_current_language_empty(test_client, chat_id): 19 | with utils.CommandContext( 20 | test_client, chat_id, "/show_current_language" 21 | ) as command: 22 | command.expect_next(texts.no_language_is_set) 23 | 24 | 25 | def test_set_first_language(test_client, chat_id): 26 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 27 | command.expect_next(texts.welcome) 28 | command.expect_next(texts.create_new_language) 29 | 30 | with utils.CommandContext(test_client, chat_id, "fi") as command: 31 | command.expect_next(texts.create_translation_language) 32 | 33 | with utils.CommandContext(test_client, chat_id, "rus") as command: 34 | command.expect_next(texts.new_language_created.format("rus->fi")) 35 | command.expect_next(texts.language_is_set.format("rus->fi")) 36 | 37 | with utils.CommandContext( 38 | test_client, chat_id, "/show_current_language" 39 | ) as command: 40 | command.expect_next(texts.current_language.format("rus->fi")) 41 | 42 | 43 | def test_set_second_language(test_client, chat_id): 44 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 45 | command.expect_any_multiple(2) 46 | 47 | with utils.CommandContext(test_client, chat_id, "/new") as command: 48 | command.expect_any() 49 | 50 | with utils.CommandContext(test_client, chat_id, "abc") as command: 51 | command.expect_any() 52 | 53 | with utils.CommandContext(test_client, chat_id, "bca") as command: 54 | command.expect_any_multiple(2) 55 | 56 | with utils.CommandContext( 57 | test_client, chat_id, "/show_current_language" 58 | ) as command: 59 | command.expect_next(texts.current_language.format("bca->abc")) 60 | 61 | 62 | def test_delete_language(test_client, chat_id): 63 | with utils.CommandContext(test_client, chat_id, "/delete_language") as command: 64 | command.expect_any() 65 | 66 | with utils.CommandContext(test_client, chat_id, "Yes!") as command: 67 | command.expect_any() 68 | 69 | with utils.CommandContext( 70 | test_client, chat_id, "/show_current_language" 71 | ) as command: 72 | command.expect_next(texts.no_language_is_set) 73 | -------------------------------------------------------------------------------- /tests/test_show_languages.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | 18 | def test_show_languages_empty(test_client, chat_id): 19 | with utils.CommandContext(test_client, chat_id, "/show_languages") as command: 20 | command.expect_next(texts.show_languages_none) 21 | 22 | 23 | def test_set_first_language(test_client, chat_id): 24 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 25 | command.expect_any_multiple(2) 26 | 27 | with utils.CommandContext(test_client, chat_id, "fi") as command: 28 | command.expect_any() 29 | 30 | with utils.CommandContext(test_client, chat_id, "rus") as command: 31 | command.expect_any_multiple(2) 32 | 33 | with utils.CommandContext(test_client, chat_id, "/show_languages") as command: 34 | command.expect_any() 35 | 36 | 37 | def test_set_second_language(test_client, chat_id): 38 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 39 | command.expect_any_multiple(2) 40 | 41 | with utils.CommandContext(test_client, chat_id, "/new") as command: 42 | command.expect_any_multiple(1) 43 | 44 | with utils.CommandContext(test_client, chat_id, "chinese") as command: 45 | command.expect_any() 46 | 47 | with utils.CommandContext(test_client, chat_id, "en") as command: 48 | command.expect_any_multiple(2) 49 | 50 | with utils.CommandContext(test_client, chat_id, "/show_languages") as command: 51 | command.expect_next( 52 | texts.available_languages.format(2, "💚 en->chinese\n🖤 rus->fi") 53 | ) 54 | 55 | 56 | def test_set_third_language(test_client, chat_id): 57 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 58 | command.expect_any_multiple(2) 59 | 60 | with utils.CommandContext(test_client, chat_id, "/new") as command: 61 | command.expect_any_multiple(1) 62 | 63 | with utils.CommandContext(test_client, chat_id, "abc") as command: 64 | command.expect_any() 65 | 66 | with utils.CommandContext(test_client, chat_id, "cba") as command: 67 | command.expect_any_multiple(2) 68 | 69 | with utils.CommandContext(test_client, chat_id, "/show_languages") as command: 70 | command.expect_next( 71 | texts.available_languages.format(3, "💚 cba->abc\n🖤 en->chinese\n🖤 rus->fi") 72 | ) 73 | 74 | 75 | def test_delete_language(test_client, chat_id): 76 | with utils.CommandContext(test_client, chat_id, "/delete_language") as command: 77 | command.expect_any() 78 | 79 | with utils.CommandContext(test_client, chat_id, "Yes!") as command: 80 | command.expect_any() 81 | 82 | with utils.CommandContext(test_client, chat_id, "/show_languages") as command: 83 | command.expect_next( 84 | texts.available_languages.format(2, "🖤 en->chinese\n🖤 rus->fi") 85 | ) 86 | -------------------------------------------------------------------------------- /tests/test_delete_group.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | 27 | def test_create_groups(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 29 | command.expect_any() 30 | 31 | with utils.CommandContext(test_client, chat_id, "abc") as command: 32 | command.expect_any() 33 | 34 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 35 | command.expect_any() 36 | 37 | with utils.CommandContext(test_client, chat_id, "cba") as command: 38 | command.expect_any() 39 | 40 | 41 | def test_delete_group_exit(test_client, chat_id): 42 | with utils.CommandContext(test_client, chat_id, "/delete_group") as command: 43 | command.expect_next(texts.group_choose) 44 | 45 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 46 | command.expect_next(texts.exited) 47 | 48 | 49 | def test_delete_group_cancel(test_client, chat_id): 50 | with utils.CommandContext(test_client, chat_id, "/delete_group") as command: 51 | command.expect_next(texts.group_choose) 52 | 53 | with utils.CommandContext(test_client, chat_id, "abc") as command: 54 | command.expect_next(texts.delete_group_warning.format("abc", "rus->fi")) 55 | 56 | with utils.CommandContext(test_client, chat_id, "No..") as command: 57 | command.expect_next(texts.delete_group_cancel) 58 | 59 | 60 | def test_delete_group(test_client, chat_id): 61 | with utils.CommandContext(test_client, chat_id, "/delete_group") as command: 62 | command.expect_next(texts.group_choose) 63 | 64 | with utils.CommandContext(test_client, chat_id, "abc") as command: 65 | command.expect_next(texts.delete_group_warning.format("abc", "rus->fi")) 66 | 67 | with utils.CommandContext(test_client, chat_id, "Yes!") as command: 68 | command.expect_next(texts.delete_group_success.format("abc")) 69 | 70 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 71 | command.expect_any() 72 | 73 | with utils.CommandContext(test_client, chat_id, "abc") as command: 74 | command.expect_next(texts.no_such_group) 75 | 76 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 77 | command.expect_any() 78 | 79 | 80 | def test_delete_group_nonexistent(test_client, chat_id): 81 | with utils.CommandContext(test_client, chat_id, "/delete_group") as command: 82 | command.expect_next(texts.group_choose) 83 | 84 | with utils.CommandContext(test_client, chat_id, "gjgjgjgjg") as command: 85 | command.expect_next(texts.no_such_group) 86 | command.expect_next(texts.group_choose) 87 | 88 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 89 | command.expect_any() 90 | -------------------------------------------------------------------------------- /logs.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | 4 | from pythonjsonlogger import jsonlogger 5 | from telebot.types import CallbackQuery, Message 6 | 7 | 8 | # https://cloud.yandex.com/en/docs/functions/operations/function/logs-write#function-examples 9 | class YcLoggingFormatter(jsonlogger.JsonFormatter): 10 | def add_fields(self, log_record, record, message_dict): 11 | super(YcLoggingFormatter, self).add_fields(log_record, record, message_dict) 12 | log_record["logger"] = record.name 13 | log_record["level"] = str.replace( 14 | str.replace(record.levelname, "WARNING", "WARN"), "CRITICAL", "FATAL" 15 | ) 16 | 17 | 18 | logHandler = logging.StreamHandler() 19 | logHandler.setFormatter(YcLoggingFormatter("%(message)s %(level)s %(logger)s")) 20 | 21 | loggers = [logging.getLogger(name) for name in logging.root.manager.loggerDict] 22 | for logger in loggers: 23 | logger.setLevel(logging.DEBUG) 24 | 25 | logger = logging.getLogger("logger") 26 | logger.propagate = True 27 | logger.addHandler(logHandler) 28 | logger.setLevel(logging.DEBUG) 29 | 30 | 31 | def find_in_args(args, target_type): 32 | for arg in args: 33 | if isinstance(arg, target_type): 34 | return arg 35 | 36 | 37 | def find_in_kwargs(kwargs, target_type): 38 | for kwarg in kwargs.values(): 39 | if isinstance(kwarg, target_type): 40 | return kwarg 41 | 42 | 43 | def get_message_info(*args, **kwargs): 44 | chat_id, text = "UNKNOWN", "UNKNOWN" 45 | 46 | if find_in_args(args, Message) is not None: 47 | message = find_in_args(args, Message) 48 | chat_id, text = message.chat.id, message.text 49 | elif find_in_args(args, CallbackQuery): 50 | call = find_in_args(args, CallbackQuery) 51 | chat_id, text = call.message.chat.id, call.message.text 52 | elif find_in_kwargs(kwargs, Message) is not None: 53 | message = find_in_kwargs(args, Message) 54 | chat_id, text = message.chat.id, message.text 55 | elif find_in_kwargs(kwargs, CallbackQuery): 56 | call = find_in_kwargs(kwargs, CallbackQuery) 57 | chat_id, text = call.message.chat.id, call.message.text 58 | 59 | return chat_id, text 60 | 61 | 62 | def logged_execution(func): 63 | def wrapper(*args, **kwargs): 64 | chat_id, text = get_message_info(*args, **kwargs) 65 | 66 | logger.info( 67 | "[LOG] Starting {} - chat_id {}".format(func.__name__, chat_id), 68 | extra={ 69 | "text": text, 70 | "arg": "{}".format(args), 71 | "kwarg": "{}".format(kwargs), 72 | }, 73 | ) 74 | try: 75 | result = func(*args, **kwargs) 76 | logger.info( 77 | "[LOG] Finished {} - chat_id {}".format(func.__name__, chat_id), 78 | extra={ 79 | "text": text, 80 | "arg": "{}".format(args), 81 | "kwarg": "{}".format(kwargs), 82 | "result": str(result), 83 | }, 84 | ) 85 | return result 86 | except Exception as e: 87 | logger.error( 88 | "[LOG] Failed {} - chat_id {} - exception {}".format( 89 | func.__name__, chat_id, e 90 | ), 91 | extra={ 92 | "text": text, 93 | "arg": "{}".format(args), 94 | "kwarg": "{}".format(kwargs), 95 | "error": e, 96 | "traceback": traceback.format_exc(), 97 | }, 98 | ) 99 | 100 | return wrapper 101 | -------------------------------------------------------------------------------- /tests/test_show_words.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | 27 | def test_show_words_empty(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/show_words") as command: 29 | command.expect_next(texts.no_words_yet) 30 | 31 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 32 | command.expect_none() 33 | 34 | 35 | def test_add_words(test_client, chat_id): 36 | words_to_add = list(map(str, range(10))) 37 | 38 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 39 | command.expect_any() 40 | 41 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 42 | command.expect_any() 43 | 44 | with utils.CommandContext(test_client, chat_id, "\n".join(words_to_add)) as command: 45 | command.expect_any_multiple(2) 46 | 47 | for word in words_to_add: 48 | with utils.CommandContext(test_client, chat_id, word) as command: 49 | command.expect_any() 50 | 51 | 52 | def test_show_words_1_page(test_client, chat_id): 53 | with utils.CommandContext(test_client, chat_id, "/show_words") as command: 54 | command.expect_next(texts.words_count.format(10, "en")) 55 | command.expect_next(texts.choose_sorting) 56 | 57 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 58 | command.expect_next_prefix("Page 1 of 1:") 59 | 60 | with utils.CommandContext(test_client, chat_id, "/next") as command: 61 | command.expect_none() 62 | 63 | 64 | def test_add_more_words(test_client, chat_id): 65 | words_to_add = list(map(str, range(10, 25))) 66 | 67 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 68 | command.expect_any() 69 | 70 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 71 | command.expect_any() 72 | 73 | with utils.CommandContext(test_client, chat_id, "\n".join(words_to_add)) as command: 74 | command.expect_any_multiple(2) 75 | 76 | for word in words_to_add: 77 | with utils.CommandContext(test_client, chat_id, word) as command: 78 | command.expect_any() 79 | 80 | 81 | def test_show_words_2_pages(test_client, chat_id): 82 | with utils.CommandContext(test_client, chat_id, "/show_words") as command: 83 | command.expect_next(texts.words_count.format(25, "en")) 84 | command.expect_next(texts.choose_sorting) 85 | 86 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 87 | command.expect_next_prefix("Page 1 of 2:") 88 | 89 | with utils.CommandContext(test_client, chat_id, "/next") as command: 90 | command.expect_next_prefix("Page 2 of 2:") 91 | 92 | with utils.CommandContext(test_client, chat_id, "/next") as command: 93 | command.expect_none() 94 | 95 | 96 | def test_show_words_2_pages_exit(test_client, chat_id): 97 | with utils.CommandContext(test_client, chat_id, "/show_words") as command: 98 | command.expect_next(texts.words_count.format(25, "en")) 99 | command.expect_next(texts.choose_sorting) 100 | 101 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 102 | command.expect_next_prefix("Page 1 of 2:") 103 | 104 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 105 | command.expect_next(texts.exited) 106 | 107 | with utils.CommandContext(test_client, chat_id, "/next") as command: 108 | command.expect_none() 109 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | 4 | class CommandContext: 5 | def __init__(self, client, chat_id, command): 6 | self.client = client 7 | self.chat_id = chat_id 8 | self.command = command 9 | self.step = 1 10 | 11 | def __enter__(self): 12 | self.message = self.client.send_message(chat_id=self.chat_id, text=self.command) 13 | return self 14 | 15 | def __exit__(self, exc_type, exc_val, exc_tb): 16 | return 17 | 18 | def expect_next(self, correct_response, sleep_s=0.2, timeout_s=60): 19 | assert correct_response is not None, "correct_response should be specified" 20 | 21 | timer = 0 22 | while timer <= timeout_s: 23 | response = self.client.get_messages( 24 | self.chat_id, self.message.id + self.step 25 | ).text 26 | if response is not None: # found target message 27 | self.step += 1 28 | assert response == correct_response, ( 29 | f"'{self.command}' failed due to wrong reaction on step {self.step - 1}" 30 | f"\nreaction: {response}\nexpected: {correct_response}" 31 | ) 32 | return 33 | 34 | timer += sleep_s 35 | sleep(sleep_s) 36 | raise AssertionError("no response when it's expected") 37 | 38 | def expect_next_prefix(self, correct_response_prefix, sleep_s=0.2, timeout_s=60): 39 | assert ( 40 | correct_response_prefix is not None 41 | ), "correct_response should be specified" 42 | 43 | timer = 0 44 | while timer <= timeout_s: 45 | response = self.client.get_messages( 46 | self.chat_id, self.message.id + self.step 47 | ).text 48 | if response is not None: # found target message 49 | self.step += 1 50 | assert response.startswith(correct_response_prefix), ( 51 | f"'{self.command}' failed due to wrong reaction prefix on step {self.step - 1}" 52 | f"\nreaction: {response}\nexpected: {correct_response_prefix}" 53 | ) 54 | return 55 | 56 | timer += sleep_s 57 | sleep(sleep_s) 58 | raise AssertionError("no response when it's expected") 59 | 60 | def expect_none(self, sleep_s=0.5, timeout_s=2): 61 | timer = 0 62 | while timer <= timeout_s: 63 | response = self.client.get_messages( 64 | self.chat_id, self.message.id + self.step 65 | ).text 66 | assert response is None, ( 67 | f"'{self.command}' failed due to presence of reaction on step {self.step}" 68 | f"\nreaction: {response}" 69 | ) 70 | 71 | timer += sleep_s 72 | sleep(sleep_s) 73 | 74 | def expect_length(self, num_rows, sleep_s=0.5, timeout_s=60): 75 | raise NotImplementedError 76 | 77 | def expect_any(self, sleep_s=0.2, timeout_s=60): 78 | timer = 0 79 | while timer <= timeout_s: 80 | response = self.client.get_messages( 81 | self.chat_id, self.message.id + self.step 82 | ).text 83 | if response is not None: # found target message 84 | self.step += 1 85 | break 86 | 87 | timer += sleep_s 88 | sleep(sleep_s) 89 | 90 | assert ( 91 | response is not None 92 | ), f"'{self.command}' failed due to absence of reaction on step {self.step - 1}" 93 | 94 | def expect_any_multiple(self, number_of_responses, sleep_s=0.2, timeout_s=60): 95 | for i in range(number_of_responses): 96 | self.expect_any(sleep_s=sleep_s, timeout_s=timeout_s) 97 | 98 | def expect_next_number_of_rows(self, n_rows, sleep_s=0.2, timeout_s=60): 99 | timer = 0 100 | while timer <= timeout_s: 101 | response = self.client.get_messages( 102 | self.chat_id, self.message.id + self.step 103 | ).text 104 | if response is not None: # found target message 105 | self.step += 1 106 | assert len(response.split("\n")) == n_rows, ( 107 | f"'{self.command}' failed due to wrong number of rows on step {self.step - 1}" 108 | f"\nreaction: {response}\nexpected number of rows: {n_rows}" 109 | ) 110 | return 111 | 112 | timer += sleep_s 113 | sleep(sleep_s) 114 | raise AssertionError("no response when it's expected") 115 | -------------------------------------------------------------------------------- /tests/test_set_language.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | 18 | def test_set_language_cancel(test_client, chat_id): 19 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 20 | command.expect_next(texts.welcome) 21 | command.expect_next(texts.create_new_language) 22 | 23 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 24 | command.expect_next(texts.cancel_short) 25 | 26 | 27 | def test_set_language_new(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 29 | command.expect_next(texts.create_new_language) 30 | 31 | with utils.CommandContext(test_client, chat_id, "fi") as command: 32 | command.expect_next(texts.create_translation_language) 33 | 34 | with utils.CommandContext(test_client, chat_id, "rus") as command: 35 | command.expect_next(texts.new_language_created.format("rus->fi")) 36 | command.expect_next(texts.language_is_set.format("rus->fi")) 37 | 38 | 39 | def test_set_language_new_cancel(test_client, chat_id): 40 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 41 | command.expect_next(texts.current_language.format("rus->fi")) 42 | command.expect_next(texts.set_language) 43 | 44 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 45 | command.expect_next(texts.cancel_short) 46 | 47 | 48 | def test_set_second_language(test_client, chat_id): 49 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 50 | command.expect_next(texts.current_language.format("rus->fi")) 51 | command.expect_next(texts.set_language) 52 | 53 | with utils.CommandContext(test_client, chat_id, "/new") as command: 54 | command.expect_next(texts.create_new_language) 55 | 56 | with utils.CommandContext(test_client, chat_id, "🇮🇩") as command: 57 | command.expect_next(texts.create_translation_language) 58 | 59 | with utils.CommandContext(test_client, chat_id, "🇷🇺") as command: 60 | command.expect_next(texts.new_language_created.format("🇷🇺->🇮🇩")) 61 | command.expect_next(texts.language_is_set.format("🇷🇺->🇮🇩")) 62 | 63 | 64 | def test_set_switch_language(test_client, chat_id): 65 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 66 | command.expect_next(texts.current_language.format("🇷🇺->🇮🇩")) 67 | command.expect_next(texts.set_language) 68 | 69 | with utils.CommandContext(test_client, chat_id, "rus->fi") as command: 70 | command.expect_next(texts.language_is_set.format("rus->fi")) 71 | 72 | 73 | def test_language_already_exists(test_client, chat_id): 74 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 75 | command.expect_next(texts.current_language.format("rus->fi")) 76 | command.expect_next(texts.set_language) 77 | 78 | with utils.CommandContext(test_client, chat_id, "/new") as command: 79 | command.expect_next(texts.create_new_language) 80 | 81 | with utils.CommandContext(test_client, chat_id, "🇮🇩") as command: 82 | command.expect_next(texts.create_translation_language) 83 | 84 | with utils.CommandContext(test_client, chat_id, "🇷🇺") as command: 85 | command.expect_next(texts.language_already_exists.format("🇷🇺->🇮🇩")) 86 | 87 | 88 | def test_set_wrong_language_format(test_client, chat_id): 89 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 90 | command.expect_next(texts.current_language.format("rus->fi")) 91 | command.expect_next(texts.set_language) 92 | 93 | with utils.CommandContext(test_client, chat_id, "/new") as command: 94 | command.expect_next(texts.create_new_language) 95 | 96 | with utils.CommandContext(test_client, chat_id, "abc cba") as command: 97 | command.expect_next(texts.bad_language_format) 98 | 99 | with utils.CommandContext(test_client, chat_id, "abc123") as command: 100 | command.expect_next(texts.bad_language_format) 101 | 102 | with utils.CommandContext(test_client, chat_id, "🐍") as command: 103 | command.expect_next(texts.bad_language_format) 104 | 105 | with utils.CommandContext(test_client, chat_id, "😄") as command: 106 | command.expect_next(texts.bad_language_format) 107 | 108 | with utils.CommandContext(test_client, chat_id, "🏴󠁧󠁢󠁳󠁣󠁴󠁿") as command: 109 | command.expect_next(texts.create_translation_language) 110 | 111 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 112 | command.expect_next(texts.cancel_short) 113 | -------------------------------------------------------------------------------- /bot/states.py: -------------------------------------------------------------------------------- 1 | from telebot.handler_backends import State, StatesGroup 2 | from telebot.storage.base_storage import StateContext, StateStorageBase 3 | 4 | import database.model as db_model 5 | from logs import logger 6 | 7 | # https://github.com/eternnoir/pyTelegramBotAPI/blob/0f52ca688ffb7af6176d2f73fca92335dc3560eb/telebot/handler_backends.py#L163 8 | # class State: 9 | # def __init__(self) -> None: 10 | # self.name = None 11 | 12 | # def __str__(self) -> str: 13 | # return self.name 14 | 15 | 16 | # based on Telebot example 17 | # https://github.com/eternnoir/pyTelegramBotAPI/blob/0f52ca688ffb7af6176d2f73fca92335dc3560eb/telebot/storage/redis_storage.py 18 | class StateYDBStorage(StateStorageBase): 19 | """ 20 | This class is for YDB storage to be used by the bot to track user states. 21 | """ 22 | 23 | def __init__(self, ydb_pool): 24 | super().__init__() 25 | self.pool = ydb_pool 26 | 27 | def set_data(self, chat_id, user_id, key, value): 28 | """ 29 | Set data for a user in a particular chat. 30 | """ 31 | if db_model.get_state(self.pool, chat_id) is None: 32 | return False 33 | 34 | full_state = db_model.get_state(self.pool, chat_id) 35 | data = full_state["data"] 36 | data[key] = value 37 | full_state["data"] = data 38 | 39 | db_model.set_state(self.pool, chat_id, full_state) 40 | return True 41 | 42 | def get_data(self, chat_id, user_id): 43 | """ 44 | Get data for a user in a particular chat. 45 | """ 46 | full_state = db_model.get_state(self.pool, chat_id) 47 | if full_state: 48 | return full_state.get("data", {}) 49 | 50 | return {} 51 | 52 | def set_state(self, chat_id, user_id, state): 53 | logger.debug(f"SET STATE chat_id: {chat_id}, state: {state}") 54 | if hasattr(state, "name"): 55 | state = state.name 56 | 57 | data = self.get_data(chat_id, user_id) 58 | full_state = {"state": state, "data": data} 59 | db_model.set_state(self.pool, chat_id, full_state) 60 | return True 61 | 62 | def delete_state(self, chat_id, user_id): 63 | """ 64 | Delete state for a particular user. 65 | """ 66 | if db_model.get_state(self.pool, chat_id) is None: 67 | return False 68 | 69 | db_model.clear_state(self.pool, chat_id) 70 | return True 71 | 72 | def reset_data(self, chat_id, user_id): 73 | """ 74 | Reset data for a particular user in a chat. 75 | """ 76 | full_state = db_model.get_state(self.pool, chat_id) 77 | if full_state: 78 | full_state["data"] = {} 79 | db_model.set_state(self.pool, chat_id, full_state) 80 | return True 81 | return False 82 | 83 | def get_state(self, chat_id, user_id): 84 | logger.debug(f"GET STATE chat_id: {chat_id}") 85 | states = db_model.get_state(self.pool, chat_id) 86 | logger.debug("states: {}".format(states)) 87 | if states is None: 88 | return None 89 | logger.debug( 90 | "GET STATE FINISH {}, type {}".format( 91 | states.get("state"), type(states.get("state")) 92 | ) 93 | ) 94 | return states.get("state") 95 | 96 | def get_interactive_data(self, chat_id, user_id): 97 | return StateContext(self, chat_id, user_id) 98 | 99 | def save(self, chat_id, user_id, data): 100 | full_state = db_model.get_state(self.pool, chat_id) 101 | if full_state: 102 | full_state["data"] = data 103 | db_model.set_state(self.pool, chat_id, full_state) 104 | return False 105 | 106 | 107 | class ForgetMeState(StatesGroup): 108 | init = State() 109 | 110 | 111 | class CreateLanguageState(StatesGroup): 112 | choose_language = State() 113 | choose_translation_language = State() 114 | 115 | 116 | class SetLanguageState(StatesGroup): 117 | choose_language = State() 118 | new_language = State() 119 | new_language_from = State() 120 | 121 | 122 | class AddWordsState(StatesGroup): 123 | choose_mode = State() 124 | add_words_one_by_one = State() 125 | translate_one_by_one = State() 126 | add_words_together = State() 127 | 128 | 129 | class ShowWordsState(StatesGroup): 130 | choose_sort = State() 131 | show_words = State() 132 | 133 | 134 | class DeleteLanguageState(StatesGroup): 135 | init = State() 136 | 137 | 138 | class DeleteWordsState(StatesGroup): 139 | init = State() 140 | 141 | 142 | class CreateGroupState(StatesGroup): 143 | init = State() 144 | 145 | 146 | class DeleteGroupState(StatesGroup): 147 | select_group = State() 148 | are_you_sure = State() 149 | 150 | 151 | class AddGroupWordsState(StatesGroup): 152 | choose_group = State() 153 | choose_sorting = State() 154 | choose_words = State() 155 | 156 | 157 | class DeleteGroupWordsState(StatesGroup): 158 | choose_group = State() 159 | choose_sorting = State() 160 | choose_words = State() 161 | 162 | 163 | class ShowGroupsState(StatesGroup): 164 | init = State() 165 | 166 | 167 | class TrainState(StatesGroup): 168 | choose_strategy = State() 169 | choose_group = State() 170 | choose_direction = State() 171 | choose_duration = State() 172 | choose_hints = State() 173 | train = State() 174 | -------------------------------------------------------------------------------- /word.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | 5 | 6 | def ifnull(x, replace): 7 | if x is None: 8 | return replace 9 | return x 10 | 11 | 12 | def get_translation_pretty(db): 13 | return "/".join(json.loads(db["translation"])) 14 | 15 | 16 | def get_word(info, order="from"): 17 | if order == "from": 18 | return info["word"] 19 | elif order == "to": 20 | return get_translation_pretty(info) 21 | 22 | 23 | def get_translation(info, order="from"): 24 | if order == "from": 25 | return get_translation_pretty(info) 26 | elif order == "to": 27 | return info["word"] 28 | 29 | 30 | def get_hint(info, order="from", hint="no hints"): 31 | if hint == "no hints": 32 | return None 33 | if hint == "a****z": 34 | translation = get_translation(info, order) 35 | translation = translation.split("/")[0] 36 | if len(translation) <= 2: 37 | return "*" * len(translation) 38 | return translation[0] + "*" * (len(translation) - 2) + translation[-1] 39 | if hint == "test": 40 | pass 41 | 42 | 43 | def compare_user_input_with_db(user_input, db, hints_type=None, order="from"): 44 | if hints_type == "flashcards": 45 | return True 46 | if order == "from": 47 | return any( 48 | t == user_input.lower().strip() for t in json.loads(db["translation"]) 49 | ) 50 | elif order == "to": 51 | return user_input.lower().strip() == db["word"] 52 | 53 | 54 | def get_overall_score(db): 55 | if db["score_to"] is None and db["score_from"] is None: 56 | return None 57 | 58 | if db["score_to"] is None: 59 | return db["score_from"] / db["n_trains_from"] 60 | 61 | if db["score_from"] is None: 62 | return db["score_to"] / db["n_trains_to"] 63 | 64 | return ( 65 | 1 / 2 * db["score_from"] / db["n_trains_from"] 66 | + 1 / 2 * db["score_to"] / db["n_trains_to"] 67 | ) 68 | 69 | 70 | def get_total_trains(db): 71 | return ifnull(db["n_trains_from"], 0) + ifnull(db["n_trains_to"], 0) 72 | 73 | 74 | def sample_hints(current_word, words, max_hints_number=3): 75 | other_words = list(filter(lambda w: w["word"] != current_word["word"], words)) 76 | hints = random.sample(other_words, k=min(len(other_words), max_hints_number)) 77 | return hints 78 | 79 | 80 | def get_az_hint(word): 81 | word_versions = word.split("/") 82 | masks = [] 83 | 84 | for version in word_versions: 85 | if len(version) <= 4: 86 | masks.append("*" * len(version)) 87 | else: 88 | masks.append(version[0] + "*" * (len(version) - 2) + version[-1]) 89 | 90 | return "/".join(masks) 91 | 92 | 93 | def format_train_message(word, translation, hints_type): 94 | if hints_type == "flashcards": 95 | return "{}\n\n||{}||".format( 96 | re.escape(word), 97 | re.escape(translation) 98 | + " " * max(40 - len(translation), 0) 99 | + "ㅤ", # invisible symbol to extend spoiler 100 | ) 101 | 102 | if hints_type == "a****z": 103 | return "{}\n{}".format(re.escape(word), re.escape(get_az_hint(translation))) 104 | 105 | return "{}".format(re.escape(word)) 106 | 107 | 108 | def get_reaction_to_score(score): 109 | if score is None: 110 | return "🖤" 111 | if score < 0.2: 112 | return "💔" 113 | if score < 0.5: 114 | return "❤️" 115 | if score < 0.7: 116 | return "🧡" 117 | if score < 0.85: 118 | return "💛" 119 | return "💚" 120 | 121 | 122 | def format_word_for_listing(db): 123 | if db["score"] is None: 124 | return "{}` ???? {:>4} {} - {}`".format( 125 | get_reaction_to_score(db["score"]), 126 | db["n_trains"], 127 | db["word"], 128 | "/".join(json.loads(db["translation"])), 129 | ) 130 | 131 | return "{}` {:>3}% {:>4} {} - {}`".format( 132 | get_reaction_to_score(db["score"]), 133 | int(db["score"] * 100), 134 | db["n_trains"], 135 | db["word"], 136 | "/".join(json.loads(db["translation"])), 137 | ) 138 | 139 | 140 | def format_word_for_group_action(db): 141 | return "{} - {}".format(json.loads(db["translation"])[0], db["word"]) 142 | 143 | 144 | def get_word_from_group_action(word): 145 | return word[1:].split(" - ")[1] 146 | 147 | 148 | def get_word_idx(vocabulary, word): 149 | for i, entry in enumerate(vocabulary): 150 | if entry["word"] == word: 151 | return i 152 | 153 | 154 | class Word: 155 | def __init__(self, db_entry): 156 | self.db_entry = db_entry 157 | 158 | def get_score(self, score_type): 159 | assert score_type in ( 160 | "from", 161 | "to", 162 | ), "score_type should be one of ('from', 'to')" 163 | if self.db_entry[f"score_{score_type}"] is None: 164 | return None 165 | return ( 166 | self.db_entry[f"score_{score_type}"] 167 | / self.db_entry[f"n_trains_{score_type}"] 168 | ) 169 | 170 | def get_overall_score(self): 171 | score_to = self.get_score("to") 172 | score_from = self.get_score("from") 173 | 174 | if score_to is None and score_from is None: 175 | return None 176 | 177 | if score_to is None: 178 | return score_from 179 | 180 | if score_from is None: 181 | return score_to 182 | 183 | return (score_to + score_from) / 2 184 | 185 | def get_total_trains(self): 186 | return ifnull(self.db_entry["n_trains_from"], 0) + ifnull( 187 | self.db_entry["n_trains_to"], 0 188 | ) 189 | -------------------------------------------------------------------------------- /tests/test_add_words.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | 27 | def test_add_words_one_by_one(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 29 | command.expect_next(texts.add_words_choose_mode) 30 | 31 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 32 | command.expect_next(texts.add_words_instruction_one_by_one_1) 33 | 34 | with utils.CommandContext(test_client, chat_id, "a\nb\nc") as command: 35 | command.expect_next(texts.add_words_instruction_one_by_one_2.format(3)) 36 | command.expect_next(texts.add_words_translate.format("a")) 37 | 38 | with utils.CommandContext(test_client, chat_id, "a1") as command: 39 | command.expect_next("b") 40 | 41 | with utils.CommandContext(test_client, chat_id, "b1") as command: 42 | command.expect_next("c") 43 | 44 | with utils.CommandContext(test_client, chat_id, "c1") as command: 45 | command.expect_next(texts.add_words_finished.format(3)) 46 | 47 | with utils.CommandContext(test_client, chat_id, "anything") as command: 48 | command.expect_none() 49 | 50 | 51 | def test_add_words_cancel_one_by_one(test_client, chat_id): 52 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 53 | command.expect_next(texts.add_words_choose_mode) 54 | 55 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 56 | command.expect_next(texts.add_words_instruction_one_by_one_1) 57 | 58 | with utils.CommandContext(test_client, chat_id, "d\ne\nf") as command: 59 | command.expect_next(texts.add_words_instruction_one_by_one_2.format(3)) 60 | command.expect_next(texts.add_words_translate.format("d")) 61 | 62 | with utils.CommandContext(test_client, chat_id, "d1") as command: 63 | command.expect_next("e") 64 | 65 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 66 | command.expect_next(texts.add_words_cancelled) 67 | 68 | with utils.CommandContext(test_client, chat_id, "f1") as command: 69 | command.expect_none() 70 | 71 | 72 | def test_add_words_cancel_choose_mode(test_client, chat_id): 73 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 74 | command.expect_next(texts.add_words_choose_mode) 75 | 76 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 77 | command.expect_next(texts.cancel_short) 78 | 79 | 80 | def test_add_words_cancel_one_by_one_add(test_client, chat_id): 81 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 82 | command.expect_next(texts.add_words_choose_mode) 83 | 84 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 85 | command.expect_next(texts.add_words_instruction_one_by_one_1) 86 | 87 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 88 | command.expect_next(texts.cancel_short) 89 | 90 | 91 | def test_add_words_cancel_together_add(test_client, chat_id): 92 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 93 | command.expect_next(texts.add_words_choose_mode) 94 | 95 | with utils.CommandContext(test_client, chat_id, "together") as command: 96 | command.expect_next(texts.add_words_together_instruction) 97 | 98 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 99 | command.expect_next(texts.cancel_short) 100 | 101 | 102 | def test_add_words_together(test_client, chat_id): 103 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 104 | command.expect_next(texts.add_words_choose_mode) 105 | 106 | with utils.CommandContext(test_client, chat_id, "together") as command: 107 | command.expect_next(texts.add_words_together_instruction) 108 | 109 | words = """a = a1/ a2 110 | b = b1 111 | c = c1/c2 / c3 112 | """ 113 | with utils.CommandContext(test_client, chat_id, words) as command: 114 | command.expect_next(texts.add_words_finished.format(3)) 115 | 116 | 117 | def test_add_words_together_wrong_format(test_client, chat_id): 118 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 119 | command.expect_next(texts.add_words_choose_mode) 120 | 121 | with utils.CommandContext(test_client, chat_id, "together") as command: 122 | command.expect_next(texts.add_words_together_instruction) 123 | 124 | with utils.CommandContext(test_client, chat_id, "a = a1/ a2\nb=") as command: 125 | command.expect_next(texts.add_words_together_empty_translation.format("b=")) 126 | 127 | with utils.CommandContext(test_client, chat_id, "a = a1/ a2=\nb=") as command: 128 | command.expect_next(texts.add_words_together_wrong_format.format("a = a1/ a2=")) 129 | 130 | with utils.CommandContext( 131 | test_client, chat_id, "a = a1/ a2\nb=b1\n \t=c2/c3" 132 | ) as command: 133 | command.expect_next(texts.add_words_together_empty_word.format("=c2/c3")) 134 | 135 | with utils.CommandContext( 136 | test_client, chat_id, "a = a1/ a2\nb=b1\n \tc=c2/c3" 137 | ) as command: 138 | command.expect_next(texts.add_words_finished.format(3)) 139 | -------------------------------------------------------------------------------- /tests/test_group_delete_words.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 27 | command.expect_any() 28 | 29 | with utils.CommandContext(test_client, chat_id, "abc") as command: 30 | command.expect_any() 31 | 32 | 33 | def test_prepare_add_words(test_client, chat_id): 34 | words = list(map(str, range(10))) 35 | 36 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 37 | command.expect_any() 38 | 39 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 40 | command.expect_any() 41 | 42 | with utils.CommandContext(test_client, chat_id, "\n".join(words)) as command: 43 | command.expect_any_multiple(2) 44 | 45 | for word in words: 46 | with utils.CommandContext(test_client, chat_id, word + "-1") as command: 47 | command.expect_any() 48 | 49 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 50 | command.expect_any() 51 | 52 | with utils.CommandContext(test_client, chat_id, "abc") as command: 53 | command.expect_any() 54 | 55 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 56 | command.expect_any() 57 | 58 | for word in words[:6]: 59 | with utils.CommandContext( 60 | test_client, chat_id, f"🖤{word}-1 - {word}" 61 | ) as command: 62 | command.expect_any() 63 | 64 | with utils.CommandContext(test_client, chat_id, "/next") as command: 65 | command.expect_any() 66 | 67 | for word in words[6:]: 68 | with utils.CommandContext( 69 | test_client, chat_id, f"🖤{word}-1 - {word}" 70 | ) as command: 71 | command.expect_any() 72 | 73 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 74 | command.expect_any() 75 | 76 | 77 | def test_group_delete_words_az(test_client, chat_id): 78 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 79 | command.expect_next(texts.group_choose) 80 | 81 | with utils.CommandContext(test_client, chat_id, "abc") as command: 82 | command.expect_next(texts.choose_sorting) 83 | 84 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 85 | command.expect_next(texts.group_edit_choose.format("delete", "abc", 1, 2)) 86 | 87 | with utils.CommandContext(test_client, chat_id, "💚1-1 - 1") as command: 88 | command.expect_next(texts.group_edit_confirm) 89 | 90 | with utils.CommandContext(test_client, chat_id, "🖤1-1 - 1") as command: 91 | command.expect_next(texts.group_edit_confirm) 92 | 93 | with utils.CommandContext(test_client, chat_id, "💚2-1 - 2") as command: 94 | command.expect_next(texts.group_edit_confirm) 95 | 96 | with utils.CommandContext(test_client, chat_id, "💚4-1 - 4") as command: 97 | command.expect_next(texts.group_edit_confirm) 98 | 99 | with utils.CommandContext(test_client, chat_id, "💚9-1 - 9") as command: 100 | command.expect_next(texts.group_edit_unknown_word) 101 | 102 | with utils.CommandContext(test_client, chat_id, "/next") as command: 103 | command.expect_next(texts.group_edit_choose.format("delete", "abc", 2, 2)) 104 | 105 | with utils.CommandContext(test_client, chat_id, "💚9-1 - 9") as command: 106 | command.expect_next(texts.group_edit_confirm) 107 | 108 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 109 | command.expect_next_prefix( 110 | texts.group_edit_finished[:40].format("abc", "delete", 3) 111 | ) 112 | 113 | 114 | def test_group_delete_words_az_1(test_client, chat_id): 115 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 116 | command.expect_next(texts.group_choose) 117 | 118 | with utils.CommandContext(test_client, chat_id, "abc") as command: 119 | command.expect_next(texts.choose_sorting) 120 | 121 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 122 | command.expect_next(texts.group_edit_choose.format("delete", "abc", 1, 2)) 123 | 124 | with utils.CommandContext(test_client, chat_id, "💚1-1 - 1") as command: 125 | command.expect_next(texts.group_edit_confirm) 126 | 127 | with utils.CommandContext(test_client, chat_id, "💚2-1 - 2") as command: 128 | command.expect_next(texts.group_edit_unknown_word) 129 | 130 | with utils.CommandContext(test_client, chat_id, "💚4-1 - 4") as command: 131 | command.expect_next(texts.group_edit_unknown_word) 132 | 133 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 134 | command.expect_next_prefix( 135 | texts.group_edit_finished[:40].format("abc", "delete", 1) 136 | ) 137 | 138 | 139 | def test_group_delete_words_exit(test_client, chat_id): 140 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 141 | command.expect_next(texts.group_choose) 142 | 143 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 144 | command.expect_next(texts.exited) 145 | 146 | 147 | def test_group_delete_words_exit_sorting(test_client, chat_id): 148 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 149 | command.expect_next(texts.group_choose) 150 | 151 | with utils.CommandContext(test_client, chat_id, "abc") as command: 152 | command.expect_next(texts.choose_sorting) 153 | 154 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 155 | command.expect_next(texts.exited) 156 | 157 | 158 | def test_add_words_back(test_client, chat_id): 159 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 160 | command.expect_any() 161 | 162 | with utils.CommandContext(test_client, chat_id, "abc") as command: 163 | command.expect_any() 164 | 165 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 166 | command.expect_any() 167 | 168 | with utils.CommandContext(test_client, chat_id, f"🖤1-1 - 1") as command: 169 | command.expect_next(texts.group_edit_confirm) 170 | 171 | with utils.CommandContext(test_client, chat_id, f"🖤2-1 - 2") as command: 172 | command.expect_next(texts.group_edit_confirm) 173 | 174 | with utils.CommandContext(test_client, chat_id, f"🖤5-1 - 5") as command: 175 | command.expect_next(texts.group_edit_unknown_word) 176 | 177 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 178 | command.expect_next_prefix( 179 | texts.group_edit_finished[:40].format("abc", "add", 2) 180 | ) 181 | 182 | 183 | def test_group_delete_words_cancel_first_page(test_client, chat_id): 184 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 185 | command.expect_next(texts.group_choose) 186 | 187 | with utils.CommandContext(test_client, chat_id, "abc") as command: 188 | command.expect_next(texts.choose_sorting) 189 | 190 | with utils.CommandContext(test_client, chat_id, "time added ⬇️") as command: 191 | command.expect_next(texts.group_edit_choose.format("delete", "abc", 1, 2)) 192 | 193 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 194 | command.expect_next(texts.cancel_short) 195 | 196 | 197 | def test_group_delete_words_cancel_second_page(test_client, chat_id): 198 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 199 | command.expect_next(texts.group_choose) 200 | 201 | with utils.CommandContext(test_client, chat_id, "abc") as command: 202 | command.expect_next(texts.choose_sorting) 203 | 204 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 205 | command.expect_next(texts.group_edit_choose.format("delete", "abc", 1, 2)) 206 | 207 | with utils.CommandContext(test_client, chat_id, "💚0-1 - 0") as command: 208 | command.expect_next(texts.group_edit_confirm) 209 | 210 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 211 | command.expect_next(texts.cancel_short) 212 | -------------------------------------------------------------------------------- /tests/test_show_groups.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | 27 | def test_show_empty_group(test_client, chat_id): 28 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 29 | command.expect_any() 30 | 31 | with utils.CommandContext(test_client, chat_id, "abc") as command: 32 | command.expect_any() 33 | 34 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 35 | command.expect_next(texts.group_choose) 36 | 37 | with utils.CommandContext(test_client, chat_id, "abc") as command: 38 | command.expect_next(texts.show_group_empty) 39 | 40 | 41 | def test_show_nonexistent_group(test_client, chat_id): 42 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 43 | command.expect_next(texts.group_choose) 44 | 45 | with utils.CommandContext(test_client, chat_id, "bca") as command: 46 | command.expect_next(texts.no_such_group) 47 | 48 | with utils.CommandContext(test_client, chat_id, "abc") as command: 49 | command.expect_next(texts.show_group_empty) 50 | 51 | 52 | def test_prepare_add_words(test_client, chat_id): 53 | words = list(map(str, range(10))) 54 | 55 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 56 | command.expect_next(texts.add_words_choose_mode) 57 | 58 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 59 | command.expect_next(texts.add_words_instruction_one_by_one_1) 60 | 61 | with utils.CommandContext(test_client, chat_id, "\n".join(words)) as command: 62 | command.expect_any_multiple(2) 63 | 64 | for word in words: 65 | with utils.CommandContext(test_client, chat_id, word + "-1") as command: 66 | command.expect_any() 67 | 68 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 69 | command.expect_any() 70 | 71 | with utils.CommandContext(test_client, chat_id, "abc") as command: 72 | command.expect_any() 73 | 74 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 75 | command.expect_any() 76 | 77 | for word in words[:6]: 78 | with utils.CommandContext( 79 | test_client, chat_id, f"🖤{word}-1 - {word}" 80 | ) as command: 81 | command.expect_any() 82 | 83 | with utils.CommandContext(test_client, chat_id, "/next") as command: 84 | command.expect_any() 85 | 86 | for word in words[6:]: 87 | with utils.CommandContext( 88 | test_client, chat_id, f"🖤{word}-1 - {word}" 89 | ) as command: 90 | command.expect_any() 91 | 92 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 93 | command.expect_any() 94 | 95 | 96 | def test_show_short_group(test_client, chat_id): 97 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 98 | command.expect_next(texts.group_choose) 99 | 100 | with utils.CommandContext(test_client, chat_id, "abc") as command: 101 | command.expect_next_number_of_rows(10 + 3) 102 | 103 | with utils.CommandContext(test_client, chat_id, "/next") as command: 104 | command.expect_none() 105 | 106 | 107 | def test_delete_words_from_group(test_client, chat_id): 108 | with utils.CommandContext(test_client, chat_id, "/group_delete_words") as command: 109 | command.expect_any() 110 | 111 | with utils.CommandContext(test_client, chat_id, "abc") as command: 112 | command.expect_any() 113 | 114 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 115 | command.expect_any() 116 | 117 | with utils.CommandContext(test_client, chat_id, "💚0-1 - 0") as command: 118 | command.expect_any() 119 | 120 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 121 | command.expect_any() 122 | 123 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 124 | command.expect_next(texts.group_choose) 125 | 126 | with utils.CommandContext(test_client, chat_id, "abc") as command: 127 | command.expect_next_number_of_rows(9 + 3) 128 | 129 | 130 | def test_delete_words_from_vocabulary(test_client, chat_id): 131 | with utils.CommandContext(test_client, chat_id, "/delete_words") as command: 132 | command.expect_any() 133 | 134 | with utils.CommandContext(test_client, chat_id, "1\n2") as command: 135 | command.expect_any() 136 | 137 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 138 | command.expect_next(texts.group_choose) 139 | 140 | with utils.CommandContext(test_client, chat_id, "abc") as command: 141 | command.expect_next_number_of_rows(7 + 3) 142 | 143 | 144 | def test_prepare_add_more_words(test_client, chat_id): 145 | words = list(map(str, range(10, 30))) 146 | 147 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 148 | command.expect_next(texts.add_words_choose_mode) 149 | 150 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 151 | command.expect_next(texts.add_words_instruction_one_by_one_1) 152 | 153 | with utils.CommandContext(test_client, chat_id, "\n".join(words)) as command: 154 | command.expect_any_multiple(2) 155 | 156 | for word in words: 157 | with utils.CommandContext(test_client, chat_id, word + "-1") as command: 158 | command.expect_any() 159 | 160 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 161 | command.expect_any() 162 | 163 | with utils.CommandContext(test_client, chat_id, "abc") as command: 164 | command.expect_any() 165 | 166 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 167 | command.expect_any() 168 | 169 | for word in words[:5]: 170 | with utils.CommandContext( 171 | test_client, chat_id, f"🖤{word}-1 - {word}" 172 | ) as command: 173 | command.expect_any() 174 | 175 | with utils.CommandContext(test_client, chat_id, "/next") as command: 176 | command.expect_any() 177 | 178 | for word in words[5:11]: 179 | with utils.CommandContext( 180 | test_client, chat_id, f"🖤{word}-1 - {word}" 181 | ) as command: 182 | command.expect_any() 183 | 184 | with utils.CommandContext(test_client, chat_id, "/next") as command: 185 | command.expect_any() 186 | 187 | for word in words[11:17]: 188 | with utils.CommandContext( 189 | test_client, chat_id, f"🖤{word}-1 - {word}" 190 | ) as command: 191 | command.expect_any() 192 | 193 | with utils.CommandContext(test_client, chat_id, "/next") as command: 194 | command.expect_any() 195 | 196 | for word in words[17:23]: 197 | with utils.CommandContext( 198 | test_client, chat_id, f"🖤{word}-1 - {word}" 199 | ) as command: 200 | command.expect_any() 201 | 202 | with utils.CommandContext(test_client, chat_id, "/next") as command: 203 | command.expect_any() 204 | 205 | for word in words[23:]: 206 | with utils.CommandContext( 207 | test_client, chat_id, f"🖤{word}-1 - {word}" 208 | ) as command: 209 | command.expect_any() 210 | 211 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 212 | command.expect_any() 213 | 214 | 215 | def test_show_long_group(test_client, chat_id): 216 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 217 | command.expect_next(texts.group_choose) 218 | 219 | with utils.CommandContext(test_client, chat_id, "abc") as command: 220 | command.expect_next_number_of_rows(20 + 3) 221 | 222 | with utils.CommandContext(test_client, chat_id, "/next") as command: 223 | command.expect_next_number_of_rows(7 + 3) 224 | 225 | 226 | def test_exit(test_client, chat_id): 227 | with utils.CommandContext(test_client, chat_id, "/show_groups") as command: 228 | command.expect_next(texts.group_choose) 229 | 230 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 231 | command.expect_next(texts.exited) 232 | 233 | with utils.CommandContext(test_client, chat_id, "abc") as command: 234 | command.expect_none() 235 | -------------------------------------------------------------------------------- /database/model.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | 4 | import database.queries as queries 5 | from database.utils import execute_select_query, execute_update_query 6 | 7 | WORDS_UPDATE_BUCKET_SIZE = 20 8 | GROUPS_UPDATE_BUCKET_SIZE = 20 9 | 10 | 11 | def get_current_time(): 12 | return int(datetime.datetime.timestamp(datetime.datetime.now())) 13 | 14 | 15 | def get_state(pool, chat_id): 16 | results = execute_select_query(pool, queries.get_user_state, chat_id=chat_id) 17 | if len(results) == 0: 18 | return None 19 | if results[0]["state"] is None: 20 | return None 21 | return json.loads(results[0]["state"]) 22 | 23 | 24 | def set_state(pool, chat_id, state): 25 | execute_update_query( 26 | pool, queries.set_user_state, chat_id=chat_id, state=json.dumps(state) 27 | ) 28 | 29 | 30 | def clear_state(pool, chat_id): 31 | execute_update_query(pool, queries.set_user_state, chat_id=chat_id, state=None) 32 | 33 | 34 | def create_user(pool, chat_id): 35 | execute_update_query(pool, queries.create_user, chat_id=chat_id) 36 | 37 | 38 | def get_user_info(pool, chat_id): 39 | return execute_select_query(pool, queries.get_user_info, chat_id=chat_id) 40 | 41 | 42 | def update_vocab(pool, chat_id, language, words, translations): 43 | assert len(words) == len( 44 | translations 45 | ), "words and translations should have the same length. len(words) = {}, len(translations) = {}".format( 46 | len(words), len(translations) 47 | ) 48 | added_timestamp = get_current_time() 49 | 50 | for i in range(0, len(words), WORDS_UPDATE_BUCKET_SIZE): 51 | execute_update_query( 52 | pool, 53 | queries.bulk_update_words, 54 | chat_id=chat_id, 55 | language=language.encode(), 56 | words=words[i : i + WORDS_UPDATE_BUCKET_SIZE], 57 | translations=translations[i : i + WORDS_UPDATE_BUCKET_SIZE], 58 | added_timestamp=added_timestamp, 59 | ) 60 | 61 | 62 | def delete_user(pool, chat_id): 63 | execute_update_query(pool, queries.delete_user, chat_id=chat_id) 64 | 65 | 66 | def delete_language(pool, chat_id, language): 67 | execute_update_query( 68 | pool, queries.delete_language, chat_id=chat_id, language=language.encode() 69 | ) 70 | 71 | 72 | def get_user_vocabs(pool, chat_id): 73 | return execute_select_query(pool, queries.get_user_vocabs, chat_id=chat_id) 74 | 75 | 76 | def get_full_vocab(pool, chat_id, language): 77 | return execute_select_query( 78 | pool, queries.get_full_vocab, chat_id=chat_id, language=language.encode() 79 | ) 80 | 81 | 82 | def get_words_from_vocab(pool, chat_id, language, words): 83 | return execute_select_query( 84 | pool, 85 | queries.get_words_from_vocab, 86 | chat_id=chat_id, 87 | language=language.encode(), 88 | words=words, 89 | ) 90 | 91 | 92 | def delete_words_from_vocab(pool, chat_id, language, words): 93 | execute_update_query( 94 | pool, 95 | queries.delete_words_from_vocab, 96 | chat_id=chat_id, 97 | language=language.encode(), 98 | words=words, 99 | ) 100 | 101 | 102 | def update_current_lang(pool, chat_id, language): 103 | execute_update_query( 104 | pool, queries.update_current_lang, chat_id=chat_id, language=language.encode() 105 | ) 106 | 107 | 108 | def get_available_languages(pool, chat_id): 109 | result = execute_select_query( 110 | pool, queries.get_available_languages, chat_id=chat_id 111 | ) 112 | return [row["language"].decode() for row in result] 113 | 114 | 115 | def user_add_language(pool, chat_id, language): 116 | execute_update_query( 117 | pool, queries.user_add_language, chat_id=chat_id, language=language.encode() 118 | ) 119 | 120 | 121 | def get_current_language(pool, chat_id): 122 | result = execute_select_query(pool, queries.get_current_language, chat_id=chat_id) 123 | if len(result) != 1: 124 | return None 125 | 126 | if result[0]["current_lang"] is None: 127 | return None 128 | 129 | return result[0]["current_lang"].decode() 130 | 131 | 132 | def init_training_session( 133 | pool, chat_id, session_id, strategy, language, direction, duration, hints 134 | ): 135 | execute_update_query( 136 | pool, 137 | queries.init_training_session, 138 | chat_id=chat_id, 139 | session_id=session_id, 140 | strategy=strategy.encode(), 141 | language=language.encode(), 142 | direction=direction.encode(), 143 | duration=duration, 144 | hints=hints.encode(), 145 | ) 146 | 147 | 148 | def get_session_info(pool, chat_id, session_id): 149 | return execute_select_query( 150 | pool, queries.get_session_info, chat_id=chat_id, session_id=session_id 151 | ) 152 | 153 | 154 | def create_training_session( 155 | pool, chat_id, session_id, strategy, language, direction, duration 156 | ): 157 | execute_update_query( 158 | pool, 159 | queries.create_training_session, 160 | chat_id=chat_id, 161 | session_id=session_id, 162 | strategy=strategy.encode(), 163 | language=language.encode(), 164 | direction=direction.encode(), 165 | duration=duration, 166 | ) 167 | 168 | 169 | def create_group_training_session( 170 | pool, chat_id, session_id, strategy, language, direction, duration, group_id 171 | ): 172 | execute_update_query( 173 | pool, 174 | queries.create_group_training_session, 175 | chat_id=chat_id, 176 | session_id=session_id, 177 | strategy=strategy.encode(), 178 | language=language.encode(), 179 | direction=direction.encode(), 180 | duration=duration, 181 | group_id=group_id.encode(), 182 | ) 183 | 184 | 185 | def get_training_words(pool, chat_id, session_id): 186 | return execute_select_query( 187 | pool, queries.get_training_words, chat_id=chat_id, session_id=session_id 188 | ) 189 | 190 | 191 | def set_training_scores(pool, chat_id, session_id, word_idxs, scores): 192 | execute_update_query( 193 | pool, 194 | queries.set_training_scores, 195 | chat_id=chat_id, 196 | session_id=session_id, 197 | word_idxs=word_idxs, 198 | scores=scores, 199 | ) 200 | 201 | 202 | def update_final_scores(pool, chat_id, session_id, language, direction): 203 | execute_update_query( 204 | pool, 205 | queries.update_final_scores, 206 | chat_id=chat_id, 207 | session_id=session_id, 208 | language=language.encode(), 209 | direction=direction.encode(), 210 | ) 211 | 212 | 213 | def get_group_by_name(pool, chat_id, language, group_name): 214 | return execute_select_query( 215 | pool, 216 | queries.get_group_by_name, 217 | chat_id=chat_id, 218 | language=language.encode(), 219 | group_name=group_name.encode(), 220 | ) 221 | 222 | 223 | def add_group(pool, chat_id, language, group_name, group_id, is_creator): 224 | execute_update_query( 225 | pool, 226 | queries.add_group, 227 | chat_id=chat_id, 228 | language=language.encode(), 229 | group_name=group_name.encode(), 230 | group_id=group_id.encode(), 231 | is_creator=is_creator, 232 | ) 233 | 234 | 235 | def delete_group(pool, group_id): 236 | execute_update_query(pool, queries.delete_group, group_id=group_id.encode()) 237 | 238 | 239 | def get_all_groups(pool, chat_id, language): 240 | return execute_select_query( 241 | pool, queries.get_all_groups, chat_id=chat_id, language=language.encode() 242 | ) 243 | 244 | 245 | def get_group_contents(pool, group_id): 246 | return execute_select_query( 247 | pool, queries.get_group_contents, group_id=group_id.encode() 248 | ) 249 | 250 | 251 | def add_words_to_group(pool, chat_id, language, group_id, words): 252 | words_list = list(words) 253 | for i in range(0, len(words_list), GROUPS_UPDATE_BUCKET_SIZE): 254 | execute_update_query( 255 | pool, 256 | queries.bulk_update_group, 257 | chat_id=chat_id, 258 | language=language.encode(), 259 | group_id=group_id.encode(), 260 | words=words_list[i : i + GROUPS_UPDATE_BUCKET_SIZE], 261 | ) 262 | 263 | 264 | def delete_words_from_group(pool, chat_id, language, group_id, words): 265 | words_list = list(words) 266 | for i in range(0, len(words_list), GROUPS_UPDATE_BUCKET_SIZE): 267 | execute_update_query( 268 | pool, 269 | queries.bulk_update_group_delete, 270 | chat_id=chat_id, 271 | language=language.encode(), 272 | group_id=group_id.encode(), 273 | words=words_list[i : i + GROUPS_UPDATE_BUCKET_SIZE], 274 | ) 275 | 276 | 277 | def log_command(pool, chat_id, command): 278 | timestamp = get_current_time() 279 | execute_update_query( 280 | pool, 281 | queries.log_command, 282 | chat_id=chat_id, 283 | timestamp=timestamp, 284 | command=command, 285 | ) 286 | 287 | 288 | def truncate_tables(pool): 289 | for query in queries.truncate_tables_queries: 290 | execute_update_query(pool, query) 291 | -------------------------------------------------------------------------------- /tests/test_group_add_words.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import utils 4 | from fixtures import chat_id, test_client 5 | 6 | sys.path.append("../") 7 | import user_interaction.texts as texts 8 | 9 | 10 | def test_prepare(test_client, chat_id): 11 | with utils.CommandContext(test_client, chat_id, "/stop") as command: 12 | command.expect_next(texts.stop_message) 13 | 14 | with utils.CommandContext(test_client, chat_id, "/clear_db") as command: 15 | command.expect_next("Done!") 16 | 17 | with utils.CommandContext(test_client, chat_id, "/set_language") as command: 18 | command.expect_any_multiple(2) 19 | 20 | with utils.CommandContext(test_client, chat_id, "fi") as command: 21 | command.expect_any() 22 | 23 | with utils.CommandContext(test_client, chat_id, "rus") as command: 24 | command.expect_any_multiple(2) 25 | 26 | with utils.CommandContext(test_client, chat_id, "/create_group") as command: 27 | command.expect_any() 28 | 29 | with utils.CommandContext(test_client, chat_id, "abc") as command: 30 | command.expect_any() 31 | 32 | 33 | def test_prepare_add_words(test_client, chat_id): 34 | words = list(map(str, range(12, 0, -2))) 35 | 36 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 37 | command.expect_any() 38 | 39 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 40 | command.expect_any() 41 | 42 | with utils.CommandContext(test_client, chat_id, "\n".join(words)) as command: 43 | command.expect_any_multiple(2) 44 | 45 | for word in words: 46 | with utils.CommandContext(test_client, chat_id, word + "-1") as command: 47 | command.expect_any() 48 | 49 | 50 | def test_group_add_words_az(test_client, chat_id): 51 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 52 | command.expect_next(texts.group_choose) 53 | 54 | with utils.CommandContext(test_client, chat_id, "abc") as command: 55 | command.expect_next(texts.choose_sorting) 56 | 57 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 58 | command.expect_next(texts.group_edit_choose.format("add", "abc", 1, 1)) 59 | 60 | with utils.CommandContext(test_client, chat_id, "🖤10-1 - 10") as command: 61 | command.expect_next(texts.group_edit_confirm) 62 | 63 | with utils.CommandContext(test_client, chat_id, "💚10-1 - 10") as command: 64 | command.expect_next(texts.group_edit_confirm) 65 | 66 | with utils.CommandContext(test_client, chat_id, "🖤2-1 - 2") as command: 67 | command.expect_next(texts.group_edit_confirm) 68 | 69 | with utils.CommandContext(test_client, chat_id, "🖤4-1 - 4") as command: 70 | command.expect_next(texts.group_edit_confirm) 71 | 72 | with utils.CommandContext(test_client, chat_id, "🖤5-1 - 5") as command: 73 | command.expect_next(texts.group_edit_unknown_word) 74 | 75 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 76 | command.expect_next_prefix( 77 | texts.group_edit_finished[:40].format("abc", "add", "2") 78 | ) 79 | 80 | 81 | def test_prepare_add_more_words(test_client, chat_id): 82 | words = list(map(str, range(13, 1, -2))) 83 | 84 | with utils.CommandContext(test_client, chat_id, "/add_words") as command: 85 | command.expect_any() 86 | 87 | with utils.CommandContext(test_client, chat_id, "one-by-one") as command: 88 | command.expect_any() 89 | 90 | with utils.CommandContext(test_client, chat_id, "\n".join(words)) as command: 91 | command.expect_any_multiple(2) 92 | 93 | for word in words: 94 | with utils.CommandContext(test_client, chat_id, word + "-1") as command: 95 | command.expect_any() 96 | 97 | 98 | def test_group_add_words_az_1(test_client, chat_id): 99 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 100 | command.expect_next(texts.group_choose) 101 | 102 | with utils.CommandContext(test_client, chat_id, "abc") as command: 103 | command.expect_next(texts.choose_sorting) 104 | 105 | with utils.CommandContext(test_client, chat_id, "a-z") as command: 106 | command.expect_next(texts.group_edit_choose.format("add", "abc", 1, 2)) 107 | 108 | with utils.CommandContext(test_client, chat_id, "🖤10-1 - 10") as command: 109 | command.expect_next(texts.group_edit_confirm) 110 | 111 | with utils.CommandContext(test_client, chat_id, "🖤13-1 - 13") as command: 112 | command.expect_next(texts.group_edit_confirm) 113 | 114 | with utils.CommandContext(test_client, chat_id, "💚13-1 - 13") as command: 115 | command.expect_next(texts.group_edit_confirm) 116 | 117 | with utils.CommandContext(test_client, chat_id, "🖤6-1 - 6") as command: 118 | command.expect_next(texts.group_edit_unknown_word) 119 | 120 | with utils.CommandContext(test_client, chat_id, "/next") as command: 121 | command.expect_next(texts.group_edit_choose.format("add", "abc", 2, 2)) 122 | 123 | with utils.CommandContext(test_client, chat_id, "🖤6-1 - 6") as command: 124 | command.expect_next(texts.group_edit_confirm) 125 | 126 | with utils.CommandContext(test_client, chat_id, "🖤9-1 - 9") as command: 127 | command.expect_next(texts.group_edit_confirm) 128 | 129 | with utils.CommandContext(test_client, chat_id, "💚9-1 - 9") as command: 130 | command.expect_next(texts.group_edit_confirm) 131 | 132 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 133 | command.expect_next_prefix( 134 | texts.group_edit_finished[:40].format("abc", "add", 2) 135 | ) 136 | 137 | 138 | def test_group_add_words_time_added(test_client, chat_id): 139 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 140 | command.expect_next(texts.group_choose) 141 | 142 | with utils.CommandContext(test_client, chat_id, "abc") as command: 143 | command.expect_next(texts.choose_sorting) 144 | 145 | with utils.CommandContext(test_client, chat_id, "time added ⬇️") as command: 146 | command.expect_next(texts.group_edit_choose.format("add", "abc", 1, 2)) 147 | 148 | with utils.CommandContext(test_client, chat_id, "🖤9-1 - 9") as command: 149 | command.expect_next(texts.group_edit_confirm) 150 | 151 | with utils.CommandContext(test_client, chat_id, "🖤13-1 - 13") as command: 152 | command.expect_next(texts.group_edit_confirm) 153 | 154 | with utils.CommandContext(test_client, chat_id, "💚13-1 - 13") as command: 155 | command.expect_next(texts.group_edit_confirm) 156 | 157 | with utils.CommandContext(test_client, chat_id, "🖤12-1 - 12") as command: 158 | command.expect_next(texts.group_edit_unknown_word) 159 | 160 | with utils.CommandContext(test_client, chat_id, "/next") as command: 161 | command.expect_next(texts.group_edit_choose.format("add", "abc", 2, 2)) 162 | 163 | with utils.CommandContext(test_client, chat_id, "🖤12-1 - 12") as command: 164 | command.expect_next(texts.group_edit_confirm) 165 | 166 | with utils.CommandContext(test_client, chat_id, "🖤13-1 - 13") as command: 167 | command.expect_next(texts.group_edit_unknown_word) 168 | 169 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 170 | command.expect_next_prefix( 171 | texts.group_edit_finished[:40].format("abc", "add", 2) 172 | ) 173 | 174 | 175 | def test_group_add_words_exit(test_client, chat_id): 176 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 177 | command.expect_next(texts.group_choose) 178 | 179 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 180 | command.expect_next(texts.exited) 181 | 182 | 183 | def test_group_add_words_exit_sorting(test_client, chat_id): 184 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 185 | command.expect_next(texts.group_choose) 186 | 187 | with utils.CommandContext(test_client, chat_id, "abc") as command: 188 | command.expect_next(texts.choose_sorting) 189 | 190 | with utils.CommandContext(test_client, chat_id, "/exit") as command: 191 | command.expect_next(texts.exited) 192 | 193 | 194 | def test_group_add_words_cancel_first_page(test_client, chat_id): 195 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 196 | command.expect_next(texts.group_choose) 197 | 198 | with utils.CommandContext(test_client, chat_id, "abc") as command: 199 | command.expect_next(texts.choose_sorting) 200 | 201 | with utils.CommandContext(test_client, chat_id, "time added ⬇️") as command: 202 | command.expect_next(texts.group_edit_choose.format("add", "abc", 1, 1)) 203 | 204 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 205 | command.expect_next(texts.cancel_short) 206 | 207 | 208 | def test_group_add_words_cancel_second_page(test_client, chat_id): 209 | with utils.CommandContext(test_client, chat_id, "/group_add_words") as command: 210 | command.expect_next(texts.group_choose) 211 | 212 | with utils.CommandContext(test_client, chat_id, "abc") as command: 213 | command.expect_next(texts.choose_sorting) 214 | 215 | with utils.CommandContext(test_client, chat_id, "time added ⬇️") as command: 216 | command.expect_next(texts.group_edit_choose.format("add", "abc", 1, 1)) 217 | 218 | with utils.CommandContext(test_client, chat_id, "🖤13-1 - 13") as command: 219 | command.expect_next(texts.group_edit_confirm) 220 | 221 | with utils.CommandContext(test_client, chat_id, "/cancel") as command: 222 | command.expect_next(texts.cancel_short) 223 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Language Cards Bot 2 | 3 | This is an implementation of Telegram bot for learning new foreign words. 4 | 5 | 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. 6 | 7 | ## What does the bot do 8 | 9 | The bot supports the following: 10 | 11 | 1. Adding multiple languages to study `/set_language` 12 | 2. Adding words and translations `/add_words` 13 | 3. Creating word groups within the language `/create_group` 14 | 4. Various training modes to help remembering the words `/train`: 15 | * `flashcards` - the translation is hidden under a spoiler to check yourself 16 | * `test` - 4 translations to choose an answer from 17 | * `a***z` - only first and last symbols of the words are shown, the learner has to write the whole word 18 | * `no hints` - the learner has to write the translation by themselves 19 | 5. Tracking the progress: no hints trainings produce scores for words `/show_words` 20 | 6. Choosing a specific subset of words to study 21 | * random words 22 | * newly added words - words with small number of trainings 23 | * words with low scores 24 | * words from a specific group 25 | 7. Deleting all user information from the database `/forget_me` 26 | 27 | ## How to set up an instance of the bot 28 | ### Creating Yandex Cloud function 29 | 30 | 1) Visit [Yandex Cloud page](https://cloud.yandex.com/) and click `Console` in upper right corner. Login into Yandex ID, or create an account. 31 | 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.** 32 | 3) In Yandex Console create a folder for your resources. Choose any name.
Screenshot 33 | ![Yandex Console Screenshot](screenshots/01-create-folder.png?raw=true "Title")
34 | 4) Create a service account with any name and assign it the `editor` and the `serverless.functions.invoker` roles for your folder.
Screenshot 35 | ![Yandex Console Screenshot](screenshots/04-create-service-account.png?raw=true "Title")
36 | 5) Create an API gateway with any name and the default specification.
Screenshot 37 | ![Yandex Console Screenshot](screenshots/06-create-api-gateway.png?raw=true "Title")
38 | 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 39 | ![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")
40 | 7) Copy your function ID and save for the next step.
Screenshot 41 | ![Yandex Console Screenshot](screenshots/10-copy-function-id.png?raw=true "Title")
42 | 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: 43 | ``` 44 | /fshtb-function: 45 | post: 46 | x-yc-apigateway-integration: 47 | type: cloud_functions 48 | function_id: 49 | operationId: fshtb-function 50 | ``` 51 | 52 | ### Creating a bot and linking it with the function 53 | 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 54 |
55 | 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
 56 |   start - show welcome message and bot description
 57 |   register - store your name and age in the database
 58 |   cancel - stop registering process
 59 |   show_data - show your name and age stored in the database
 60 |   delete_account - delete your info from the database
 61 |   
62 |
63 | 64 | 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"}`. 65 | -
Request 66 | 67 | ``` 68 | curl \ 69 | --request POST \ 70 | --url https://api.telegram.org/bot/setWebhook \ 71 | --header 'content-type: application/json' \ 72 | --data '{"url": "/fshtb-function"}' 73 | ``` 74 | 75 |
76 | 77 | -
Request for Windows 78 | 79 | ``` 80 | curl --request POST --url https://api.telegram.org/bot/setWebhook --header "content-type:application/json" --data "{\"url\": \"/fshtb-function\"}" 81 | ``` 82 | 83 |
84 |
85 | 86 | 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. 87 |
Successful API gateway logs 88 | 89 | ![Yandex Console Screenshot](screenshots/12-api-gateway-logs.png?raw=true "Title") 90 |
91 |
Successful function logs 92 | 93 | ![Yandex Console Screenshot](screenshots/13-function-logs.png?raw=true "Title") 94 |
95 |
96 | Note: the function does not do anything yet, except for waking up and going back to sleep. 97 |

98 | 99 | ### Creating a YDB database 100 | 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") 101 |
102 | 103 | 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 104 | ![Yandex Console Screenshot](screenshots/19-create-ydb-tables.png?raw=true "Title") 105 |
106 | 107 | -
SQL script 108 | 109 | ``` 110 | CREATE TABLE `group_contents` 111 | ( 112 | `chat_id` Int64, 113 | `language` String, 114 | `group_id` String, 115 | `word` Utf8, 116 | PRIMARY KEY (`chat_id`, `language`, `group_id`, `word`) 117 | ); 118 | 119 | COMMIT; 120 | 121 | CREATE TABLE `groups` 122 | ( 123 | `chat_id` Int64, 124 | `language` String, 125 | `group_id` String, 126 | `group_name` String, 127 | `is_creator` Bool, 128 | PRIMARY KEY (`chat_id`, `language`, `group_id`) 129 | ); 130 | 131 | COMMIT; 132 | 133 | CREATE TABLE `languages` 134 | ( 135 | `chat_id` Int64, 136 | `language` String, 137 | PRIMARY KEY (`chat_id`, `language`) 138 | ); 139 | 140 | COMMIT; 141 | 142 | CREATE TABLE `training_session_info` 143 | ( 144 | `chat_id` Int64, 145 | `session_id` Uint64, 146 | `direction` String, 147 | `duration` Uint64, 148 | `hints` String, 149 | `language` String, 150 | `strategy` String, 151 | PRIMARY KEY (`chat_id`, `session_id`) 152 | ); 153 | 154 | COMMIT; 155 | 156 | CREATE TABLE `training_sessions` 157 | ( 158 | `chat_id` Int64, 159 | `session_id` Uint64, 160 | `word_idx` Uint64, 161 | `hint` Utf8, 162 | `mistake` Bool, 163 | `score` Uint64, 164 | `translation` Utf8, 165 | `word` Utf8, 166 | PRIMARY KEY (`chat_id`, `session_id`, `word_idx`) 167 | ); 168 | 169 | COMMIT; 170 | 171 | CREATE TABLE `user_states` 172 | ( 173 | `chat_id` Int64, 174 | `state` Utf8, 175 | PRIMARY KEY (`chat_id`) 176 | ); 177 | 178 | COMMIT; 179 | 180 | CREATE TABLE `users` 181 | ( 182 | `chat_id` Int64, 183 | `current_lang` String, 184 | `session_id` Uint64, 185 | `state` Utf8, 186 | PRIMARY KEY (`chat_id`) 187 | ); 188 | 189 | COMMIT; 190 | 191 | CREATE TABLE `vocabularies` 192 | ( 193 | `chat_id` Int64, 194 | `language` String, 195 | `word` Utf8, 196 | `added_timestamp` Uint64, 197 | `last_train_from` String, 198 | `last_train_to` String, 199 | `n_trains_from` Uint64, 200 | `n_trains_to` Uint64, 201 | `score_from` Uint64, 202 | `score_to` Uint64, 203 | `translation` Utf8, 204 | PRIMARY KEY (`chat_id`, `language`, `word`) 205 | ); 206 | ``` 207 | 208 |
209 | 210 |
211 | 212 | 213 | ### Make your bot do something 214 | 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. 215 | 2) In Editor tab of function: 216 | - Choose the upload method `ZIP archive`. 217 | - Click `Attach file` and select the code archive. 218 | - Fill `Entrypoint` field with `index.handler`. 219 | - Select your service account. 220 | - Create 3 environment variables: `YDB_DATABASE`, `YDB_ENDPOINT`, `BOT_TOKEN`.
How to choose their values 221 | - `YDB_DATABASE` is a value from YDB database Overview tab: `Connection > Database`. 222 | - `YDB_ENDPOINT` is a value from YDB database Overview tab: `Connection > Endpoint`. 223 | - `BOT_TOKEN` is the token you received from BotFather after creating the new bot.
How it should look like in GUI - screenshot. 224 | ![Yandex Console Screenshot](screenshots/16-create-function-version-gui.png?raw=true "Title") 225 |
226 | 3) Click `Create version` and wait for it to be created. 227 | 228 |
229 | Awesome! Now try your bot! 230 | -------------------------------------------------------------------------------- /user_interaction/texts.py: -------------------------------------------------------------------------------- 1 | # welcome messages 2 | help_message = ( 3 | "Ahoy, sexy! I am a cute little bot for remembering " 4 | "words you've learned during your language course.\n\n" 5 | "If you are new here, check out these guides:\n" 6 | "- /howto - how to get started\n" 7 | "- /howto_training - all about training strategies and scoring\n" 8 | "- /howto_groups - creating and managing word groups\n\n" 9 | "Here's the full list of commands:\n\n" 10 | "- /help or /start to read this message again.\n" 11 | "- /set_language to set current vocabulary " 12 | "(you can add multiple and switch between them without erasing the progress).\n" 13 | "- /delete_language to delete current language with all data on words, groups, training sessions.\n" 14 | "- /add_words to add words to current vocabulary.\n" 15 | "- /show_words to print out all words you saved for current language.\n" 16 | "- /delete_words to delete some words from current vocabulary.\n" 17 | "- /create_group to create new group for words.\n" 18 | "- /delete_group to delete one of your groups.\n" 19 | "- /show_groups to show your existing groups for current language.\n" 20 | "- /group_add_words to add some words from you vocabulary to one of your groups.\n" 21 | "- /group_delete_words to delete some words from one of your groups.\n" 22 | "- /train to choose training strategy and start training.\n" 23 | "- /stop to stop training session without saving the results.\n" 24 | "- /forget_me to delete all the information I have about you: languages, words, groups, etc." 25 | ) 26 | 27 | welcome = "Hey! I can see you are new here. Welcome!" 28 | 29 | # how to 30 | how_to_text = ( 31 | "*How to start learning?*\n\n" 32 | "1\. First \- set up a language you are learning: /set\_language\.\n" 33 | "2\. Then add words to it: /add\_words\. You can see all the words you currently have using /show\_words\.\n" 34 | "3\. You're all set up\! Use /train to start memorising your words\. Learn more about training: /howto\_training\.\n\n" 35 | "You can set up multiple languages\!\n" 36 | "If you later want to delete your current language use /delete\_language\.\n" 37 | "Delete words from the current language using /delete\_words\." 38 | ) 39 | 40 | howto_training_text = ( 41 | "*How to train?*\n\n" 42 | "Use /train command to start memorising the words\.\n\n" 43 | "Training is set up with 4 choices: strategy, direction, duration and hints\.\n\n" 44 | "How to choose a strategy:\n" 45 | "\- `random` \- memorise a random sample of all your words\.\n" 46 | "\- `new` \- random words that were memorised less than 3 times before\.\n" 47 | "\- `bad` \- random words that have low score \(< 0\.7\)\.\n" 48 | "\- `group` \- words from a certain group\. Learn how to create groups: /howto\_groups\.\n\n" 49 | "Direction:\n" 50 | "Memorise how to translate words from the language you're studying or to it\.\n\n" 51 | "Duration:\n" 52 | "How many words to memorise during one training\. Choose one of the suggeted lengths or any number or All\.\n\n" 53 | "Hints:\n" 54 | "\- `flashcards` \- shows a translation under a spoiler, check yourself when you're ready\. Doesn't change word scores\!\n" 55 | "\- `test` \- shows 4 options for translation, select the correct one\. Doesn't change word scores\!\n" 56 | "\- `a****z` \- shows the first and the last letters of the translation, type in the full transaltion\. Doesn't change word scores\!\n" 57 | "\- `no hints` \- type in the full translation without any hints\. *Affects the scores*\!\n\n" 58 | "Track you training progress with /show\_words\." 59 | ) 60 | 61 | howto_groups = ( 62 | "*How to create and manage groups?*\n\n" 63 | "When you have a language set up and words added, you can unite words in groups and memorise them " 64 | "together \(use /train command with `group` strategy\)\. Each word can belong to 1 or more groups, or none at all\.\n" 65 | "Groups can be anything you want: from parts of speech to a specific topic\: politics, nature, travelling\.\n\n" 66 | "Use /create\_group to start\.\n" 67 | "Then /group\_add\_words to select which words you want to add\.\n" 68 | "Use /group\_delete\_words if you need to delete words from a group \(but not from your vocabulary\!\)\. " 69 | "Deleting words from the vocabulary also deletes them from all groups\.\n" 70 | "Use /show\_groups to see all your groups and their contents\.\n" 71 | "Use /delete\_group to delete a group\. This again does not delete the words from your vocabulary\." 72 | ) 73 | 74 | # /forget_me 75 | forget_me_warning = ( 76 | "All your languages, words, training sessions and groups will be deleted without any possibility of recovery.\n\n" 77 | "Are you sure you want to delete all information the bot has about you?" 78 | ) 79 | 80 | forget_me_final = ( 81 | "👋 Farewell, my friend! It's sad to see you go.\n" 82 | "Check /set_language to make sure you're all cleaned up." 83 | ) 84 | 85 | # /delete_language 86 | delete_language_warning = ( 87 | "You are trying to delete language {}\n\n" 88 | "All your words, training sessions and groups for this language will be deleted without any possibility of recovery.\n\n" 89 | "Are you sure you want to delete language?" 90 | ) 91 | 92 | delete_language_final = ( 93 | "Language {} is deleted.\n" 94 | "Check /set_language to make sure and to set a new language." 95 | ) 96 | 97 | # /delete_words 98 | delete_words_start = ( 99 | "Write words to delete. Each word on a new line.\n\n" 100 | "Use /cancel to exit the process." 101 | ) 102 | 103 | deleted_words_list = "Deleted {} word(s):\n{}{}" 104 | 105 | deleted_words_unknown = "\n\nOther words are unknown." 106 | 107 | # language setting 108 | current_language = "Your current language is {}." 109 | 110 | no_languages_yet = "You don't have any languages yet." 111 | 112 | set_language = "Choose one of your existent languages or create a new one:" 113 | 114 | choose_existing_language = "Choose one of you languages or use /new command" 115 | 116 | set_language_cancel = "Cancelled setting language!" 117 | 118 | new_language_created = "You've created a new language {}." 119 | 120 | language_is_set = "Language set: {}.\nYou can /add_words to it or /train" 121 | 122 | no_language_is_set = "Language not set. Set in with command /set_language." 123 | 124 | create_new_language = ( 125 | "Choose a language you want to learn. Select a flag emoji, for example 🇫🇮.\n" 126 | "Tip: type a colon ':' and a country code to find an emoji, for example ':fi'.\n\n" 127 | "If there's no appropriate emoji, write a name as text. Use one word, only latin letters." 128 | ) 129 | 130 | create_translation_language = ( 131 | "Choose a language you going to translate the words to, usually it's the language you know well.\n" 132 | "Select a flag emoji, for example 🇬🇧. If there's no appropriate emoji, write a name as text. " 133 | "Use one word, only latin letters." 134 | ) 135 | 136 | bad_language_format = "Language should be a flag emoji or one word which consists of latin letters. Try again:" 137 | 138 | language_already_exists = "You already have language {}." 139 | 140 | # show languages 141 | show_languages_none = "You don't have any languages yet. Try /set_language to add one." 142 | 143 | available_languages = "You have {} language(s):\n{}" 144 | 145 | # /add_words 146 | add_words_choose_mode = ( 147 | "Choose adding mode:\n" 148 | "- one-by-one: provide a bunch of words and translate them one by one\n" 149 | "- together: provide words with translations" 150 | ) 151 | 152 | add_words_together_instruction = ( 153 | "Write words you want to add as follows:\n\n" 154 | "first word = only translation\n" 155 | "second word = translation1 / translation2\n\n" 156 | "Multiple translations should be separated by '/'.\n" 157 | "Type /cancel to exit the process." 158 | ) 159 | 160 | add_words_together_wrong_format = ( 161 | "Entry\n'{}'\nhas wrong format.\n\n" "Try again or type /cancel." 162 | ) 163 | 164 | add_words_together_empty_word = ( 165 | "Entry\n'{}'\nhas empty word.\n\n" "Try again or type /cancel." 166 | ) 167 | 168 | add_words_together_empty_translation = ( 169 | "Entry\n'{}'\nhas empty translation.\n\n" "Try again or type /cancel." 170 | ) 171 | 172 | add_words_instruction_one_by_one_1 = ( 173 | "First, write new words you want to learn, each on new row.\n" 174 | "For example:\n" 175 | "hola\n" 176 | "gracias\n" 177 | "adiós\n\n" 178 | "After that I will ask you to provide translations.\n\n" 179 | "Type /cancel to exit the process." 180 | ) 181 | 182 | add_words_instruction_one_by_one_2 = ( 183 | "You've added {} words, now let's translate them one by one. " 184 | "Type /cancel anytime to exit the translation.\n" 185 | "You can add multiple translations divided by '/', for example:\n" 186 | "> adiós\n" 187 | "farewell / goodbye" 188 | ) 189 | 190 | add_words_finished = "Finished! Saved {} words" 191 | 192 | add_words_cancelled = "Cancelled! No words saved." 193 | 194 | add_words_none_added = "You didn't add anything. Try again /add_words?" 195 | 196 | add_words_translate = "Translate {}" 197 | 198 | # /create_group 199 | create_group_name = ( 200 | "Write new group name. It should consist only of latin letters, digits and underscores.\n" 201 | "For example, 'nouns_type_3'\n" 202 | "\n" 203 | "Use /cancel to exit the process." 204 | ) 205 | 206 | group_name_invalid = "Group name should consist only of latin letters, digits and underscores, try again or /cancel." 207 | 208 | group_already_exists = ( 209 | "You already have a group with that name, please try another: /create_group" 210 | ) 211 | 212 | group_created = "Group is created! Now add some words: /group_add_words" 213 | 214 | # sorting 215 | choose_sorting = "Choose sorting:" 216 | 217 | sorting_not_supported = "This sorting is not supported. Choose a valid option:" 218 | 219 | # misc 220 | exited = "Exited!" 221 | 222 | unknown_command_short = "I don't know this command." 223 | 224 | unknown_command = "I don't know this command, try again {}" 225 | 226 | cancel_short = "Cancelled!" 227 | 228 | unknown_message = "I don't know what to do :(" 229 | 230 | stop_message = "Stopped!" 231 | 232 | # show words 233 | words_count = "You have {} word(s) for language '{}'." 234 | 235 | no_words_yet = "You don't have any words yet, try /add_words!" 236 | 237 | word_formatted = "Page {} of {}:\n\n🤍` % # word`\n{}" 238 | 239 | # show_groups 240 | no_groups_yet = "You don't have any groups yet, try /create_group" 241 | 242 | group_choose = "Choose one of your groups" 243 | 244 | no_such_group = "You don't have a group with that name, choose again." 245 | 246 | show_group_done = "Finished group showing!" 247 | 248 | show_group_empty = "This group has no words in it yet, try /group_add_words" 249 | 250 | # delete_group 251 | group_not_a_creator = "You are not a creator of this group, can't edit or delete it." 252 | 253 | delete_group_warning = "Are you sure you want to delete group '{}' for language {}?\nThis will NOT affect words in your vocabulary!" 254 | 255 | delete_group_cancel = "Cancelled group deletion!" 256 | 257 | delete_group_success = "👍 Group '{}' successfully deleted! /show_groups" 258 | 259 | # group_edit 260 | # group_edit_choose = "Select words, page {} of {}." 261 | 262 | group_edit_confirm = "✅ㅤ" # invisible symbol to avoid large emoji 263 | 264 | group_edit_choose = "Choose words to {}. Group '{}', page {} out of {}" 265 | 266 | group_edit_finished = "Finished!\nEdited group {}: {} {} word(s).\n\n{}" 267 | 268 | group_edit_cancelled = "Cancelled! Group was not edited." 269 | 270 | group_edit_no_more_words = "That's all the words we have!" 271 | 272 | group_edit_unknown_word = "Not a word from the list, ignoring that." 273 | 274 | group_edit_full = "There're no more words to add to this group." 275 | 276 | group_edit_empty = "There're no words in this group." 277 | 278 | # training 279 | training_init = ( 280 | "Choose training strategy.\n" 281 | "Now available:\n\n" 282 | "- random - simply random words\n" 283 | "- new - only words that you've seen not more than 2 times\n" 284 | "- bad - only words with weak score\n" 285 | "- group - words from a particular group" 286 | ) 287 | 288 | training_no_words = "You current vocabulary is empty. /add_words first!" 289 | 290 | training_direction = "Choose training direction: {} ➡️ {}, or {} ⬅️ {}." 291 | 292 | training_duration = "Choose duration of your training. You can also type in any number." 293 | 294 | training_hints = ( 295 | "Choose hints for your training.\n" 296 | "Training with hints will not affect you word scores. Choose 'no hints' to track your progress." 297 | ) 298 | 299 | training_strategy_unknown = "This strategy is not supported. Choose a valid strategy:" 300 | 301 | training_direction_unknown = ( 302 | "This direction is not supported. Choose a valid direction:" 303 | ) 304 | 305 | training_duration_unknown = "This duration is not supported. Choose a valid duration:" 306 | 307 | training_hints_unknown = "These hints are not supported. Choose valid hints:" 308 | 309 | training_no_scores = "Scores are not saved because hints were used." 310 | 311 | training_results = "Score: {} / {}\n{} Training complete!\nLet's /train again?" 312 | 313 | training_cancelled = "Cancelled training, come back soon and /train again!" 314 | 315 | training_stopped = "Session stopped, results not saved.\nLet's /train again?" 316 | 317 | training_no_words_found = ( 318 | "There are no words satisfying your parameters, try choosing something else: /train" 319 | ) 320 | 321 | training_fewer_words = "(I have found fewer words than you have requested)" 322 | 323 | training_start = ( 324 | "Starting training.\n" + "Strategy: {}\nDuration: {}\nDirection: {}\nHints: {}{}" 325 | ) 326 | training_start_group = "\n\nGroup name: {}" 327 | 328 | # train reactions 329 | train_correct_answer = "✅ㅤ" # invisible symbol to avoid large emoji 330 | train_wrong_answer = "❌ {}" 331 | -------------------------------------------------------------------------------- /bot/structure.py: -------------------------------------------------------------------------------- 1 | import os 2 | from functools import partial 3 | 4 | from telebot import TeleBot, custom_filters 5 | 6 | import tests.handlers as test_handlers 7 | from bot import handlers as handlers 8 | from bot import states as bot_states 9 | 10 | 11 | class Handler: 12 | def __init__(self, callback, **kwargs): 13 | self.callback = callback 14 | self.kwargs = kwargs 15 | 16 | 17 | def get_start_handlers(): 18 | return [ 19 | Handler(handlers.handle_help, commands=["help", "start"]), 20 | ] 21 | 22 | 23 | def get_howto_handlers(): 24 | return [ 25 | Handler(handlers.handle_howto_basic, commands=["howto"]), 26 | Handler(handlers.handle_howto_training, commands=["howto_training"]), 27 | Handler(handlers.handle_howto_groups, commands=["howto_groups"]), 28 | ] 29 | 30 | 31 | def get_forget_me_handlers(): 32 | return [ 33 | Handler(handlers.handle_forget_me, commands=["forget_me"]), 34 | Handler(handlers.process_forget_me, state=bot_states.ForgetMeState.init), 35 | ] 36 | 37 | 38 | def get_set_language_handlers(): 39 | return [ 40 | Handler(handlers.handle_set_language, commands=["set_language"]), 41 | Handler( 42 | handlers.process_cancel, 43 | commands=["cancel"], 44 | state=[ 45 | bot_states.SetLanguageState.choose_language, 46 | bot_states.CreateLanguageState.choose_language, 47 | bot_states.CreateLanguageState.choose_translation_language, 48 | ], 49 | ), 50 | Handler( 51 | handlers.process_create_new_language, 52 | commands=["new"], 53 | state=bot_states.SetLanguageState.choose_language, 54 | ), 55 | Handler( 56 | handlers.process_setting_language, 57 | state=bot_states.SetLanguageState.choose_language, 58 | ), 59 | Handler( 60 | handlers.process_set_translation_language, 61 | state=bot_states.CreateLanguageState.choose_language, 62 | ), 63 | Handler( 64 | handlers.process_save_new_language, 65 | state=bot_states.CreateLanguageState.choose_translation_language, 66 | ), 67 | ] 68 | 69 | 70 | def get_add_words_handlers(): 71 | return [ 72 | Handler(handlers.handle_add_words, commands=["add_words"]), 73 | Handler( 74 | handlers.process_cancel, 75 | commands=["cancel"], 76 | state=bot_states.AddWordsState.choose_mode, 77 | ), 78 | Handler( 79 | handlers.process_add_words_mode, state=bot_states.AddWordsState.choose_mode 80 | ), 81 | Handler( 82 | handlers.process_cancel, 83 | commands=["cancel"], 84 | state=bot_states.AddWordsState.add_words_one_by_one, 85 | ), 86 | Handler( 87 | handlers.process_adding_words_one_by_one, 88 | state=bot_states.AddWordsState.add_words_one_by_one, 89 | ), 90 | Handler( 91 | handlers.process_word_translation_stop, 92 | commands=["cancel"], 93 | state=bot_states.AddWordsState.translate_one_by_one, 94 | ), 95 | Handler( 96 | handlers.process_word_translation_one_by_one, 97 | state=bot_states.AddWordsState.translate_one_by_one, 98 | ), 99 | Handler( 100 | handlers.process_cancel, 101 | commands=["cancel"], 102 | state=bot_states.AddWordsState.add_words_together, 103 | ), 104 | Handler( 105 | handlers.process_adding_words_together, 106 | state=bot_states.AddWordsState.add_words_together, 107 | ), 108 | ] 109 | 110 | 111 | def get_show_words_handlers(): 112 | return [ 113 | Handler(handlers.handle_show_words, commands=["show_words"]), 114 | Handler( 115 | handlers.process_exit, 116 | state=bot_states.ShowWordsState.choose_sort, 117 | commands=["exit"], 118 | ), 119 | Handler( 120 | handlers.process_choose_word_sort, 121 | state=bot_states.ShowWordsState.choose_sort, 122 | ), 123 | Handler( 124 | handlers.process_exit, 125 | state=bot_states.ShowWordsState.show_words, 126 | commands=["exit"], 127 | ), 128 | Handler( 129 | handlers.process_show_words_batch_next, 130 | state=bot_states.ShowWordsState.show_words, 131 | commands=["next"], 132 | ), 133 | Handler( 134 | handlers.process_show_words_batch_unknown, 135 | state=bot_states.ShowWordsState.show_words, 136 | ), 137 | ] 138 | 139 | 140 | def get_show_languages_handlers(): 141 | return [ 142 | Handler( 143 | handlers.handle_show_current_language, commands=["show_current_language"] 144 | ), 145 | Handler(handlers.handle_show_languages, commands=["show_languages"]), 146 | ] 147 | 148 | 149 | def get_delete_language_handlers(): 150 | return [ 151 | Handler(handlers.handle_delete_language, commands=["delete_language"]), 152 | Handler( 153 | handlers.process_delete_language, state=bot_states.DeleteLanguageState.init 154 | ), 155 | ] 156 | 157 | 158 | def get_delete_words_handlers(): 159 | return [ 160 | Handler(handlers.handle_delete_words, commands=["delete_words"]), 161 | Handler( 162 | handlers.process_cancel, 163 | state=bot_states.DeleteWordsState.init, 164 | commands=["cancel"], 165 | ), 166 | Handler( 167 | handlers.process_deleting_words, state=bot_states.DeleteWordsState.init 168 | ), 169 | ] 170 | 171 | 172 | def get_create_group_handlers(): 173 | return [ 174 | Handler(handlers.handle_create_group, commands=["create_group"]), 175 | Handler( 176 | handlers.process_cancel, 177 | commands=["cancel"], 178 | state=bot_states.CreateGroupState.init, 179 | ), 180 | Handler( 181 | handlers.process_group_creation, state=bot_states.CreateGroupState.init 182 | ), 183 | ] 184 | 185 | 186 | def get_delete_group_handlers(): 187 | return [ 188 | Handler(handlers.handle_delete_group, commands=["delete_group"]), 189 | Handler( 190 | handlers.process_exit, 191 | commands=["exit"], 192 | state=bot_states.DeleteGroupState.select_group, 193 | ), 194 | Handler( 195 | handlers.process_group_deletion_check_sure, 196 | state=bot_states.DeleteGroupState.select_group, 197 | ), 198 | Handler( 199 | handlers.process_group_deletion, 200 | state=bot_states.DeleteGroupState.are_you_sure, 201 | ), 202 | ] 203 | 204 | 205 | def get_show_groups_handlers(): 206 | return [ 207 | Handler(handlers.handle_show_groups, commands=["show_groups"]), 208 | Handler( 209 | handlers.process_exit, 210 | commands=["exit"], 211 | state=bot_states.ShowGroupsState.init, 212 | ), 213 | Handler( 214 | handlers.process_show_group_contents, state=bot_states.ShowGroupsState.init 215 | ), 216 | ] 217 | 218 | 219 | def get_group_add_words_handlers(): 220 | return [ 221 | Handler(handlers.handle_group_add_words, commands=["group_add_words"]), 222 | Handler( 223 | handlers.process_exit, 224 | commands=["exit"], 225 | state=bot_states.AddGroupWordsState.choose_group, 226 | ), 227 | Handler( 228 | handlers.handle_choose_group_to_add_words, 229 | state=bot_states.AddGroupWordsState.choose_group, 230 | ), 231 | Handler( 232 | handlers.process_exit, 233 | commands=["exit"], 234 | state=bot_states.AddGroupWordsState.choose_sorting, 235 | ), 236 | Handler( 237 | handlers.process_choose_sorting_to_add_words, 238 | state=bot_states.AddGroupWordsState.choose_sorting, 239 | ), 240 | Handler( 241 | handlers.process_cancel, 242 | commands=["cancel"], 243 | state=bot_states.AddGroupWordsState.choose_words, 244 | ), 245 | Handler( 246 | handlers.process_save_group_edit, 247 | commands=["exit"], 248 | state=bot_states.AddGroupWordsState.choose_words, 249 | ), 250 | Handler( 251 | handlers.process_choose_words_batch_for_group_next, 252 | commands=["next"], 253 | state=bot_states.AddGroupWordsState.choose_words, 254 | ), 255 | Handler( 256 | handlers.process_choose_words_batch_for_group, 257 | state=bot_states.AddGroupWordsState.choose_words, 258 | ), 259 | ] 260 | 261 | 262 | def get_group_delete_words_handlers(): 263 | return [ 264 | Handler(handlers.handle_group_delete_words, commands=["group_delete_words"]), 265 | Handler( 266 | handlers.process_exit, 267 | commands=["exit"], 268 | state=bot_states.DeleteGroupWordsState.choose_group, 269 | ), 270 | Handler( 271 | handlers.handle_choose_group_to_delete_words, 272 | state=bot_states.DeleteGroupWordsState.choose_group, 273 | ), 274 | Handler( 275 | handlers.process_exit, 276 | commands=["exit"], 277 | state=bot_states.DeleteGroupWordsState.choose_sorting, 278 | ), 279 | Handler( 280 | handlers.process_choose_sorting_to_delete_words, 281 | state=bot_states.DeleteGroupWordsState.choose_sorting, 282 | ), 283 | Handler( 284 | handlers.process_cancel, 285 | commands=["cancel"], 286 | state=bot_states.DeleteGroupWordsState.choose_words, 287 | ), 288 | Handler( 289 | handlers.process_save_group_edit, 290 | commands=["exit"], 291 | state=bot_states.DeleteGroupWordsState.choose_words, 292 | ), 293 | Handler( 294 | handlers.process_choose_words_batch_for_group_next, 295 | commands=["next"], 296 | state=bot_states.DeleteGroupWordsState.choose_words, 297 | ), 298 | Handler( 299 | handlers.process_choose_words_batch_for_group, 300 | state=bot_states.DeleteGroupWordsState.choose_words, 301 | ), 302 | ] 303 | 304 | 305 | def get_train_handlers(): 306 | return [ 307 | Handler(handlers.handle_train, commands=["train"]), 308 | Handler( 309 | handlers.process_cancel, 310 | commands=["cancel"], 311 | state=bot_states.TrainState.choose_strategy, 312 | ), 313 | Handler( 314 | handlers.process_choose_strategy, 315 | state=bot_states.TrainState.choose_strategy, 316 | ), 317 | Handler( 318 | handlers.process_exit, 319 | commands=["exit"], 320 | state=bot_states.TrainState.choose_group, 321 | ), 322 | Handler( 323 | handlers.process_choose_group_for_training, 324 | state=bot_states.TrainState.choose_group, 325 | ), 326 | Handler( 327 | handlers.process_cancel, 328 | commands=["cancel"], 329 | state=bot_states.TrainState.choose_direction, 330 | ), 331 | Handler( 332 | handlers.process_choose_direction, 333 | state=bot_states.TrainState.choose_direction, 334 | ), 335 | Handler( 336 | handlers.process_cancel, 337 | commands=["cancel"], 338 | state=bot_states.TrainState.choose_duration, 339 | ), 340 | Handler( 341 | handlers.process_choose_duration, 342 | state=bot_states.TrainState.choose_duration, 343 | ), 344 | Handler( 345 | handlers.process_cancel, 346 | commands=["cancel"], 347 | state=bot_states.TrainState.choose_hints, 348 | ), 349 | Handler( 350 | handlers.process_choose_hints, state=bot_states.TrainState.choose_hints 351 | ), 352 | Handler( 353 | handlers.handle_train_step_stop, 354 | commands=["stop"], 355 | state=bot_states.TrainState.train, 356 | ), 357 | Handler(handlers.handle_train_step, state=bot_states.TrainState.train), 358 | ] 359 | 360 | 361 | def get_test_handlers(): 362 | return [Handler(test_handlers.handle_clear_db, commands=["clear_db"])] 363 | 364 | 365 | def get_stop_handler(): 366 | return [ 367 | Handler(test_handlers.handle_stop, state="*", commands=["stop"]), 368 | ] 369 | 370 | 371 | def get_unknown_handler(): 372 | return [ 373 | Handler(handlers.handle_unknown), 374 | ] 375 | 376 | 377 | def create_bot(bot_token, pool): 378 | state_storage = bot_states.StateYDBStorage(pool) 379 | bot = TeleBot(bot_token, state_storage=state_storage) 380 | 381 | handlers = [] 382 | 383 | if os.getenv("IS_TESTING") is not None: 384 | handlers.extend(get_test_handlers()) 385 | handlers.extend(get_stop_handler()) 386 | 387 | handlers.extend(get_start_handlers()) 388 | handlers.extend(get_howto_handlers()) 389 | handlers.extend(get_forget_me_handlers()) 390 | handlers.extend(get_set_language_handlers()) 391 | handlers.extend(get_add_words_handlers()) 392 | handlers.extend(get_show_words_handlers()) 393 | handlers.extend(get_show_languages_handlers()) 394 | handlers.extend(get_delete_language_handlers()) 395 | handlers.extend(get_delete_words_handlers()) 396 | handlers.extend(get_create_group_handlers()) 397 | handlers.extend(get_delete_group_handlers()) 398 | handlers.extend(get_show_groups_handlers()) 399 | handlers.extend(get_group_add_words_handlers()) 400 | handlers.extend(get_group_delete_words_handlers()) 401 | handlers.extend(get_train_handlers()) 402 | 403 | handlers.extend(get_unknown_handler()) 404 | 405 | for handler in handlers: 406 | bot.register_message_handler( 407 | partial(handler.callback, pool=pool), **handler.kwargs, pass_bot=True 408 | ) 409 | 410 | bot.add_custom_filter(custom_filters.StateFilter(bot)) 411 | return bot 412 | -------------------------------------------------------------------------------- /database/queries.py: -------------------------------------------------------------------------------- 1 | USERS_TABLE_PATH = "users" 2 | VOCABS_TABLE_PATH = "vocabularies" 3 | GROUPS_TABLE_PATH = "groups" 4 | GROUPS_CONTENTS_TABLE_PATH = "group_contents" 5 | LANGUAGES_TABLE_PATH = "languages" 6 | TRAINING_SESSIONS_TABLE_PATH = "training_sessions" 7 | TRAINING_SESSIONS_INFO_TABLE_PATH = "training_session_info" 8 | STATES_TABLE_PATH = "user_states" 9 | 10 | 11 | # Manage tables queries 12 | truncate_tables_queries = [ 13 | """ 14 | DELETE FROM `{}` ON SELECT * FROM `{}` 15 | """.format( 16 | table_name, table_name 17 | ) 18 | for table_name in [ 19 | USERS_TABLE_PATH, 20 | VOCABS_TABLE_PATH, 21 | GROUPS_TABLE_PATH, 22 | GROUPS_CONTENTS_TABLE_PATH, 23 | LANGUAGES_TABLE_PATH, 24 | TRAINING_SESSIONS_TABLE_PATH, 25 | TRAINING_SESSIONS_INFO_TABLE_PATH, 26 | STATES_TABLE_PATH, 27 | ] 28 | ] 29 | 30 | # Data manipulation queries 31 | create_user = f""" 32 | DECLARE $chat_id AS Int64; 33 | 34 | INSERT INTO `{USERS_TABLE_PATH}` (chat_id) VALUES ($chat_id); 35 | """ 36 | 37 | get_user_info = f""" 38 | DECLARE $chat_id AS Int64; 39 | 40 | SELECT * FROM `{USERS_TABLE_PATH}` 41 | WHERE chat_id == $chat_id; 42 | """ 43 | 44 | get_user_state = f""" 45 | DECLARE $chat_id AS Int64; 46 | 47 | SELECT state 48 | FROM `{STATES_TABLE_PATH}` 49 | WHERE chat_id == $chat_id; 50 | """ 51 | 52 | set_user_state = f""" 53 | DECLARE $chat_id AS Int64; 54 | DECLARE $state AS Utf8?; 55 | 56 | UPSERT INTO `{STATES_TABLE_PATH}` (`chat_id`, `state`) VALUES 57 | ($chat_id, $state); 58 | """ 59 | 60 | delete_user = f""" 61 | DECLARE $chat_id AS Int64; 62 | 63 | $groups = ( 64 | SELECT group_id 65 | FROM `{GROUPS_TABLE_PATH}` 66 | WHERE 67 | chat_id == $chat_id 68 | AND is_creator 69 | ); 70 | 71 | DELETE FROM `{VOCABS_TABLE_PATH}` 72 | WHERE chat_id == $chat_id; 73 | 74 | DELETE FROM `{USERS_TABLE_PATH}` 75 | WHERE chat_id == $chat_id; 76 | 77 | DELETE FROM `{LANGUAGES_TABLE_PATH}` 78 | WHERE chat_id == $chat_id; 79 | 80 | DELETE FROM `{TRAINING_SESSIONS_INFO_TABLE_PATH}` 81 | WHERE chat_id == $chat_id; 82 | 83 | DELETE FROM `{TRAINING_SESSIONS_TABLE_PATH}` 84 | WHERE chat_id == $chat_id; 85 | 86 | DELETE FROM `{STATES_TABLE_PATH}` 87 | WHERE chat_id == $chat_id; 88 | 89 | DELETE FROM `{GROUPS_TABLE_PATH}` 90 | WHERE 91 | chat_id == $chat_id 92 | OR group_id IN $groups; 93 | 94 | DELETE FROM `{GROUPS_CONTENTS_TABLE_PATH}` 95 | WHERE 96 | chat_id == $chat_id 97 | OR group_id IN $groups; 98 | """ 99 | 100 | delete_language = f""" 101 | DECLARE $chat_id AS Int64; 102 | DECLARE $language AS String; 103 | 104 | $groups = ( 105 | SELECT group_id 106 | FROM `{GROUPS_TABLE_PATH}` 107 | WHERE 108 | chat_id == $chat_id 109 | AND language == $language 110 | AND is_creator 111 | ); 112 | $sessions = ( 113 | SELECT session_id 114 | FROM `{TRAINING_SESSIONS_INFO_TABLE_PATH}` 115 | WHERE 116 | chat_id == $chat_id 117 | AND language == $language 118 | ); 119 | 120 | DELETE FROM `{VOCABS_TABLE_PATH}` 121 | WHERE 122 | chat_id == $chat_id 123 | AND language == $language; 124 | 125 | UPDATE `{USERS_TABLE_PATH}` 126 | SET current_lang = NULL 127 | WHERE chat_id == $chat_id; 128 | 129 | DELETE FROM `{LANGUAGES_TABLE_PATH}` 130 | WHERE 131 | chat_id == $chat_id 132 | AND language == $language; 133 | 134 | DELETE FROM `{TRAINING_SESSIONS_INFO_TABLE_PATH}` 135 | WHERE 136 | chat_id == $chat_id 137 | AND language == $language; 138 | 139 | DELETE FROM `{TRAINING_SESSIONS_TABLE_PATH}` 140 | WHERE 141 | chat_id == $chat_id 142 | AND session_id IN $sessions; 143 | 144 | DELETE FROM `{GROUPS_TABLE_PATH}` 145 | WHERE 146 | ( 147 | chat_id == $chat_id 148 | AND language == $language 149 | ) 150 | OR group_id IN $groups; 151 | 152 | DELETE FROM `{GROUPS_CONTENTS_TABLE_PATH}` 153 | WHERE 154 | ( 155 | chat_id == $chat_id 156 | AND language == $language 157 | ) 158 | OR group_id IN $groups; 159 | """ 160 | 161 | get_user_vocabs = f""" 162 | DECLARE $chat_id AS Int64; 163 | 164 | SELECT * FROM `{VOCABS_TABLE_PATH}` 165 | WHERE chat_id == $chat_id 166 | """ 167 | 168 | get_full_vocab = f""" 169 | DECLARE $chat_id AS Int64; 170 | DECLARE $language AS String; 171 | 172 | SELECT 173 | word, 174 | score_from, 175 | score_to, 176 | n_trains_from, 177 | n_trains_to, 178 | translation, 179 | added_timestamp, 180 | FROM `{VOCABS_TABLE_PATH}` 181 | WHERE 182 | chat_id == $chat_id 183 | AND language == $language 184 | """ 185 | 186 | get_words_from_vocab = f""" 187 | DECLARE $chat_id AS Int64; 188 | DECLARE $language AS String; 189 | DECLARE $words AS List; 190 | 191 | SELECT word FROM `{VOCABS_TABLE_PATH}` 192 | WHERE 193 | chat_id == $chat_id 194 | AND language == $language 195 | AND word IN $words 196 | """ 197 | 198 | delete_words_from_vocab = f""" 199 | DECLARE $chat_id AS Int64; 200 | DECLARE $language AS String; 201 | DECLARE $words AS List; 202 | 203 | DELETE FROM `{VOCABS_TABLE_PATH}` 204 | WHERE 205 | chat_id == $chat_id 206 | AND language == $language 207 | AND word IN $words 208 | """ 209 | 210 | update_current_lang = f""" 211 | DECLARE $chat_id AS Int64; 212 | DECLARE $language AS String; 213 | 214 | UPDATE `{USERS_TABLE_PATH}` 215 | SET current_lang = $language 216 | WHERE chat_id == $chat_id; 217 | """ 218 | 219 | get_available_languages = f""" 220 | DECLARE $chat_id AS Int64; 221 | 222 | SELECT language 223 | FROM `{LANGUAGES_TABLE_PATH}` 224 | WHERE chat_id == $chat_id 225 | """ 226 | 227 | user_add_language = f""" 228 | DECLARE $chat_id AS Int64; 229 | DECLARE $language AS Utf8; 230 | 231 | INSERT INTO `{LANGUAGES_TABLE_PATH}` (`chat_id`, `language`) VALUES 232 | ($chat_id, $language) 233 | """ 234 | 235 | get_current_language = f""" 236 | DECLARE $chat_id AS Int64; 237 | 238 | SELECT current_lang 239 | FROM `{USERS_TABLE_PATH}` 240 | WHERE chat_id == $chat_id 241 | """ 242 | 243 | init_training_session = f""" 244 | DECLARE $chat_id AS Int64; 245 | DECLARE $session_id AS Uint64; 246 | DECLARE $strategy AS String; 247 | DECLARE $language AS Utf8; 248 | DECLARE $direction AS String; 249 | DECLARE $duration AS Uint64; 250 | DECLARE $hints AS String; 251 | 252 | UPDATE `{USERS_TABLE_PATH}` 253 | SET session_id = $session_id 254 | WHERE chat_id == $chat_id; 255 | 256 | UPSERT INTO `{TRAINING_SESSIONS_INFO_TABLE_PATH}` 257 | (`chat_id`, `session_id`, `strategy`, `language`, `direction`, `duration`, `hints`) VALUES 258 | ($chat_id, $session_id, $strategy, $language, $direction, $duration, $hints) 259 | """ 260 | 261 | get_session_info = f""" 262 | DECLARE $chat_id AS Int64; 263 | DECLARE $session_id AS Uint64; 264 | 265 | SELECT * 266 | FROM `{TRAINING_SESSIONS_INFO_TABLE_PATH}` 267 | WHERE chat_id == $chat_id AND session_id == $session_id 268 | """ 269 | 270 | create_training_session = f""" 271 | DECLARE $chat_id AS Int64; 272 | DECLARE $session_id AS Uint64; 273 | DECLARE $language AS Utf8; 274 | DECLARE $direction AS String; 275 | DECLARE $duration AS Uint64; 276 | DECLARE $strategy AS String; 277 | 278 | $words_sample = ( 279 | SELECT 280 | CAST($chat_id AS Uint64) AS chat_id, 281 | $session_id AS session_id, 282 | word, 283 | translation, 284 | ROW_NUMBER() OVER w AS word_idx, 285 | FROM `{VOCABS_TABLE_PATH}` 286 | WHERE 287 | chat_id == $chat_id 288 | AND language == $language 289 | AND CASE 290 | WHEN $strategy == "new" AND $direction == "to" 291 | THEN NVL(n_trains_to, 0) <= 2 292 | WHEN $strategy == "new" AND $direction == "from" 293 | THEN NVL(n_trains_from, 0) <= 2 294 | WHEN $strategy == "bad" AND $direction == "to" 295 | THEN n_trains_to >= 1 AND 1.0 * score_to / n_trains_to <= 0.7 296 | WHEN $strategy == "bad" AND $direction == "from" 297 | THEN n_trains_from >= 1 AND 1.0 * score_from / n_trains_from <= 0.7 298 | ELSE True 299 | END 300 | WINDOW w AS ( 301 | ORDER BY RandomNumber(CAST($session_id AS String) || word) 302 | ) 303 | ); 304 | 305 | UPSERT INTO `{TRAINING_SESSIONS_TABLE_PATH}` 306 | SELECT * FROM $words_sample 307 | WHERE word_idx <= $duration; 308 | """ 309 | 310 | create_group_training_session = f""" 311 | DECLARE $chat_id AS Int64; 312 | DECLARE $session_id AS Uint64; 313 | DECLARE $language AS Utf8; 314 | DECLARE $duration AS Uint64; 315 | DECLARE $strategy AS String; 316 | DECLARE $group_id AS String; 317 | 318 | $words_sample = ( 319 | SELECT 320 | CAST($chat_id AS Uint64) AS chat_id, 321 | $session_id AS session_id, 322 | v.word AS word, 323 | v.translation AS translation, 324 | ROW_NUMBER() OVER w AS word_idx, 325 | FROM `{VOCABS_TABLE_PATH}` AS v 326 | INNER JOIN `{GROUPS_CONTENTS_TABLE_PATH}` AS g USING (chat_id, language, word) 327 | WHERE 328 | v.chat_id == $chat_id 329 | AND v.language == $language 330 | AND g.group_id == $group_id 331 | WINDOW w AS ( 332 | ORDER BY RandomNumber(CAST($session_id AS String) || v.word) 333 | ) 334 | ); 335 | 336 | UPSERT INTO `{TRAINING_SESSIONS_TABLE_PATH}` 337 | SELECT * FROM $words_sample 338 | WHERE word_idx <= $duration; 339 | """ 340 | 341 | get_training_words = f""" 342 | DECLARE $chat_id AS Int64; 343 | DECLARE $session_id AS Uint64; 344 | 345 | SELECT * FROM `{TRAINING_SESSIONS_TABLE_PATH}` 346 | WHERE 347 | session_id == $session_id 348 | AND chat_id == $chat_id 349 | ORDER BY word_idx; 350 | """ 351 | 352 | set_training_scores = f""" 353 | DECLARE $chat_id AS Int64; 354 | DECLARE $session_id AS Uint64; 355 | DECLARE $word_idxs AS List; 356 | DECLARE $scores AS List; 357 | 358 | $new_scores = ( 359 | SELECT 360 | CAST($chat_id AS Uint64) AS chat_id, 361 | $session_id AS session_id, 362 | ListZip($word_idxs, $scores) AS scores, 363 | ); 364 | 365 | UPSERT INTO `{TRAINING_SESSIONS_TABLE_PATH}` 366 | SELECT 367 | chat_id, 368 | session_id, 369 | scores.0 AS word_idx, 370 | scores.1 AS score, 371 | FROM $new_scores 372 | FLATTEN LIST BY scores 373 | """ 374 | 375 | update_final_scores = f""" 376 | DECLARE $chat_id AS Int64; 377 | DECLARE $session_id AS Uint64; 378 | DECLARE $language AS Utf8; 379 | DECLARE $direction AS String; 380 | 381 | $format_dttm = DateTime::Format("%Y-%m-%d %H:%M:%S"); 382 | $get_dttm = ($session_id) -> ( 383 | $format_dttm(DateTime::FromSeconds( 384 | CAST($session_id AS Uint32) 385 | )) 386 | ); 387 | 388 | $current_words = ( 389 | SELECT 390 | word, 391 | score, 392 | FROM `{TRAINING_SESSIONS_TABLE_PATH}` 393 | WHERE 394 | chat_id == $chat_id 395 | AND session_id == $session_id 396 | ); 397 | 398 | $new_scores = ( 399 | SELECT 400 | v.*, 401 | IF($direction == "to", $get_dttm($session_id), v.last_train_to) AS last_train_to, 402 | IF($direction == "from", $get_dttm($session_id), v.last_train_from) AS last_train_from, 403 | IF($direction == "to", NVL(v.n_trains_to, 0) + 1, v.n_trains_to) AS n_trains_to, 404 | IF($direction == "from", NVL(v.n_trains_from, 0) + 1, v.n_trains_from) AS n_trains_from, 405 | IF($direction == "to", NVL(v.score_to, 0) + cw.score, v.score_to) AS score_to, 406 | IF($direction == "from", NVL(v.score_from, 0) + cw.score, v.score_from) AS score_from, 407 | WITHOUT 408 | v.last_train_from, 409 | v.last_train_to, 410 | v.n_trains_from, 411 | v.n_trains_to, 412 | v.score_from, 413 | v.score_to 414 | FROM `{VOCABS_TABLE_PATH}` AS v 415 | INNER JOIN $current_words AS cw ON v.word == cw.word 416 | WHERE 417 | v.chat_id == $chat_id 418 | AND v.language == $language 419 | ); 420 | 421 | UPDATE `{VOCABS_TABLE_PATH}` ON 422 | SELECT * FROM $new_scores; 423 | 424 | UPDATE `{USERS_TABLE_PATH}` 425 | SET session_id = NULL 426 | WHERE chat_id == $chat_id; 427 | """ 428 | 429 | get_group_by_name = f""" 430 | DECLARE $chat_id AS Int64; 431 | DECLARE $language AS Utf8; 432 | DECLARE $group_name AS String; 433 | 434 | SELECT group_id, group_name, is_creator 435 | FROM `{GROUPS_TABLE_PATH}` 436 | WHERE 437 | chat_id == $chat_id 438 | AND language == $language 439 | AND group_name == $group_name 440 | """ 441 | 442 | add_group = f""" 443 | DECLARE $chat_id AS Int64; 444 | DECLARE $language AS Utf8; 445 | DECLARE $group_id AS String; 446 | DECLARE $group_name AS String; 447 | DECLARE $is_creator AS Bool; 448 | 449 | INSERT INTO `{GROUPS_TABLE_PATH}` 450 | (chat_id, language, group_id, group_name, is_creator) VALUES 451 | ($chat_id, $language, $group_id, $group_name, $is_creator); 452 | """ 453 | 454 | delete_group = f""" 455 | DECLARE $group_id AS String; 456 | 457 | DELETE FROM `{GROUPS_CONTENTS_TABLE_PATH}` 458 | WHERE group_id == $group_id; 459 | 460 | DELETE FROM `{GROUPS_TABLE_PATH}` 461 | WHERE group_id == $group_id; 462 | """ 463 | 464 | get_all_groups = f""" 465 | DECLARE $chat_id AS Int64; 466 | DECLARE $language AS Utf8; 467 | 468 | SELECT group_name, group_id FROM `{GROUPS_TABLE_PATH}` 469 | WHERE 470 | chat_id == $chat_id 471 | AND language == $language 472 | """ 473 | 474 | get_group_contents = f""" 475 | DECLARE $group_id AS String; 476 | 477 | SELECT 478 | group_contents.word AS word, 479 | translation, 480 | score_from, 481 | score_to, 482 | n_trains_from, 483 | n_trains_to, 484 | added_timestamp, 485 | FROM `{GROUPS_CONTENTS_TABLE_PATH}` AS group_contents 486 | INNER JOIN `{VOCABS_TABLE_PATH}` AS vocabs ON 487 | group_contents.chat_id == vocabs.chat_id 488 | AND group_contents.language == vocabs.language 489 | AND group_contents.word == vocabs.word 490 | WHERE 491 | group_contents.group_id == $group_id 492 | """ 493 | 494 | bulk_update_words = f""" 495 | DECLARE $chat_id AS Int64; 496 | DECLARE $language AS String; 497 | DECLARE $words AS List; 498 | DECLARE $translations AS List; 499 | DECLARE $added_timestamp AS Uint64; 500 | 501 | $update_table = ( 502 | SELECT 503 | t.*, 504 | t.word_info.0 AS word, 505 | t.word_info.1 AS translation, 506 | WITHOUT t.word_info 507 | FROM ( 508 | SELECT 509 | $chat_id AS chat_id, 510 | $language AS language, 511 | CAST(NULL AS String?) AS last_train_from, 512 | CAST(NULL AS String?) AS last_train_to, 513 | CAST(NULL AS Uint64?) AS score_from, 514 | CAST(NULL AS Uint64?) AS score_to, 515 | CAST(NULL AS Uint64?) AS n_trains_from, 516 | CAST(NULL AS Uint64?) AS n_trains_to, 517 | $added_timestamp AS added_timestamp, 518 | ListZip($words, $translations) AS word_info, 519 | ) AS t 520 | FLATTEN LIST BY word_info 521 | ); 522 | 523 | UPSERT INTO `vocabularies` 524 | SELECT * FROM $update_table; 525 | """ 526 | 527 | bulk_update_group = f""" 528 | DECLARE $chat_id AS Int64; 529 | DECLARE $language AS String; 530 | DECLARE $group_id AS String; 531 | DECLARE $words AS List; 532 | 533 | $update_table = ( 534 | SELECT * 535 | FROM ( 536 | SELECT 537 | $chat_id AS chat_id, 538 | $language AS language, 539 | $group_id AS group_id, 540 | $words AS word 541 | ) 542 | FLATTEN LIST BY word 543 | ); 544 | 545 | UPSERT INTO `{GROUPS_CONTENTS_TABLE_PATH}` 546 | SELECT * FROM $update_table; 547 | """ 548 | 549 | bulk_update_group_delete = f""" 550 | DECLARE $chat_id AS Int64; 551 | DECLARE $language AS String; 552 | DECLARE $group_id AS String; 553 | DECLARE $words AS List; 554 | 555 | DELETE FROM `{GROUPS_CONTENTS_TABLE_PATH}` 556 | WHERE 557 | chat_id == $chat_id 558 | AND language == $language 559 | AND group_id == $group_id 560 | AND word IN $words; 561 | """ 562 | 563 | log_command = f""" 564 | DECLARE $chat_id AS Int64; 565 | DECLARE $timestamp AS Uint64; 566 | DECLARE $command AS Utf8; 567 | 568 | UPSERT INTO `command_log` 569 | SELECT 570 | $chat_id AS chat_id, 571 | $timestamp AS timestamp, 572 | $command AS command; 573 | """ 574 | -------------------------------------------------------------------------------- /bot/handlers.py: -------------------------------------------------------------------------------- 1 | import json 2 | from hashlib import blake2b 3 | 4 | import database.model as db_model 5 | import word as word_utils 6 | from bot import constants, keyboards, states, utils 7 | from logs import logged_execution, logger 8 | from user_interaction import config, options, texts 9 | 10 | 11 | # common 12 | @logged_execution 13 | def process_exit(message, bot, pool): 14 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 15 | if "init_message" in data: 16 | utils.clear_history( 17 | bot, message.chat.id, data["init_message"], message.id + 1 18 | ) 19 | 20 | bot.delete_state(message.from_user.id, message.chat.id) 21 | bot.send_message(message.chat.id, texts.exited, reply_markup=keyboards.empty) 22 | 23 | 24 | @logged_execution 25 | def process_cancel(message, bot, pool): 26 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 27 | if "init_message" in data: 28 | utils.clear_history( 29 | bot, message.chat.id, data["init_message"], message.id + 1 30 | ) 31 | 32 | bot.delete_state(message.from_user.id, message.chat.id) 33 | bot.send_message(message.chat.id, texts.cancel_short, reply_markup=keyboards.empty) 34 | 35 | 36 | @logged_execution 37 | def handle_help(message, bot, pool): 38 | db_model.log_command(pool, message.chat.id, message.text) 39 | bot.send_message(message.chat.id, texts.help_message, reply_markup=keyboards.empty) 40 | 41 | 42 | @logged_execution 43 | def handle_howto_basic(message, bot, pool): 44 | db_model.log_command(pool, message.chat.id, message.text) 45 | bot.send_message( 46 | message.chat.id, 47 | texts.how_to_text, 48 | reply_markup=keyboards.empty, 49 | parse_mode="MarkdownV2", 50 | ) 51 | 52 | 53 | @logged_execution 54 | def handle_howto_training(message, bot, pool): 55 | db_model.log_command(pool, message.chat.id, message.text) 56 | bot.send_message( 57 | message.chat.id, 58 | texts.howto_training_text, 59 | reply_markup=keyboards.empty, 60 | parse_mode="MarkdownV2", 61 | ) 62 | 63 | 64 | @logged_execution 65 | def handle_howto_groups(message, bot, pool): 66 | db_model.log_command(pool, message.chat.id, message.text) 67 | bot.send_message( 68 | message.chat.id, 69 | texts.howto_groups, 70 | reply_markup=keyboards.empty, 71 | parse_mode="MarkdownV2", 72 | ) 73 | 74 | 75 | @logged_execution 76 | def handle_forget_me(message, bot, pool): 77 | db_model.log_command(pool, message.chat.id, message.text) 78 | markup = keyboards.get_reply_keyboard(options.delete_are_you_sure) 79 | bot.set_state(message.from_user.id, states.ForgetMeState.init, message.chat.id) 80 | bot.send_message(message.chat.id, texts.forget_me_warning, reply_markup=markup) 81 | 82 | 83 | # forget me 84 | @logged_execution 85 | def process_forget_me(message, bot, pool): 86 | bot.delete_state(message.from_user.id, message.chat.id) 87 | 88 | if message.text not in options.delete_are_you_sure: 89 | bot.reply_to(message, texts.unknown_command_short) 90 | return 91 | 92 | if options.delete_are_you_sure[message.text]: 93 | db_model.delete_user(pool, message.chat.id) 94 | bot.send_message( 95 | message.chat.id, texts.forget_me_final, reply_markup=keyboards.empty 96 | ) 97 | else: 98 | bot.send_message( 99 | message.chat.id, texts.cancel_short, reply_markup=keyboards.empty 100 | ) 101 | 102 | 103 | @logged_execution 104 | def handle_unknown(message, bot, pool): 105 | # bot.reply_to(message, texts.unknown_message) 106 | logger.warning( 107 | f"Unknown message! chat_id: {message.chat.id}, message: {message.text}" 108 | ) 109 | 110 | 111 | # set language 112 | 113 | 114 | @logged_execution 115 | def handle_set_language(message, bot, pool): 116 | db_model.log_command(pool, message.chat.id, message.text) 117 | language = db_model.get_current_language(pool, message.chat.id) 118 | if language is not None: 119 | bot.send_message( 120 | message.chat.id, 121 | texts.current_language.format(language), 122 | reply_markup=keyboards.empty, 123 | ) 124 | 125 | languages = db_model.get_available_languages(pool, message.chat.id) 126 | bot.set_state( 127 | message.from_user.id, states.SetLanguageState.choose_language, message.chat.id 128 | ) 129 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 130 | data["languages"] = languages 131 | 132 | if len(languages) == 0: 133 | process_create_new_language(message, bot, pool) 134 | return 135 | 136 | markup = keyboards.get_reply_keyboard(languages, ["/new", "/cancel"]) 137 | bot.send_message(message.chat.id, texts.set_language, reply_markup=markup) 138 | 139 | 140 | @logged_execution 141 | def process_create_new_language(message, bot, pool): 142 | user_info = db_model.get_user_info(pool, message.chat.id) 143 | 144 | if len(user_info) == 0: # new user! 145 | bot.send_message(message.chat.id, texts.welcome, reply_markup=keyboards.empty) 146 | db_model.create_user(pool, message.chat.id) 147 | 148 | bot.set_state( 149 | message.from_user.id, 150 | states.CreateLanguageState.choose_language, 151 | message.chat.id, 152 | ) 153 | 154 | markup = keyboards.get_reply_keyboard(["/cancel"]) 155 | bot.send_message( 156 | message.chat.id, 157 | texts.create_new_language, 158 | reply_markup=markup, 159 | ) 160 | 161 | 162 | @logged_execution 163 | def process_set_translation_language(message, bot, pool): 164 | if not utils.check_language_name(message.text.lower().strip()): 165 | bot.send_message( 166 | message.chat.id, 167 | texts.bad_language_format, 168 | reply_markup=keyboards.get_reply_keyboard(["/cancel"]), 169 | ) 170 | return 171 | 172 | bot.set_state( 173 | message.from_user.id, 174 | states.CreateLanguageState.choose_translation_language, 175 | message.chat.id, 176 | ) 177 | 178 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 179 | data["language"] = message.text.lower().strip() 180 | 181 | markup = keyboards.get_reply_keyboard(["/cancel"]) 182 | bot.send_message( 183 | message.chat.id, 184 | texts.create_translation_language, 185 | reply_markup=markup, 186 | ) 187 | 188 | 189 | @logged_execution 190 | def process_save_new_language(message, bot, pool): 191 | if not utils.check_language_name(message.text.lower().strip()): 192 | bot.send_message( 193 | message.chat.id, 194 | texts.bad_language_format, 195 | reply_markup=keyboards.get_reply_keyboard(["/cancel"]), 196 | ) 197 | return 198 | 199 | translation_language = message.text.lower().strip() 200 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 201 | language = data["language"] 202 | languages = data["languages"] 203 | 204 | bot.delete_state(message.from_user.id, message.chat.id) 205 | 206 | full_language_name = f"{translation_language}->{language}" 207 | if full_language_name in languages: 208 | bot.send_message( 209 | message.chat.id, 210 | texts.language_already_exists.format(full_language_name), 211 | reply_markup=keyboards.empty, 212 | ) 213 | return 214 | 215 | bot.send_message( 216 | message.chat.id, 217 | texts.new_language_created.format(full_language_name), 218 | reply_markup=keyboards.empty, 219 | ) 220 | db_model.user_add_language(pool, message.chat.id, full_language_name) 221 | db_model.update_current_lang(pool, message.chat.id, full_language_name) 222 | bot.send_message( 223 | message.chat.id, 224 | texts.language_is_set.format(full_language_name), 225 | reply_markup=keyboards.empty, 226 | ) 227 | 228 | 229 | @logged_execution 230 | def process_setting_language(message, bot, pool): 231 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 232 | if message.text not in data["languages"]: 233 | markup = keyboards.get_reply_keyboard( 234 | data["languages"], ["/new", "/cancel"] 235 | ) 236 | bot.send_message( 237 | message.chat.id, texts.choose_existing_language, reply_markup=markup 238 | ) 239 | return 240 | 241 | bot.delete_state(message.from_user.id, message.chat.id) 242 | 243 | db_model.update_current_lang(pool, message.chat.id, message.text) 244 | bot.send_message( 245 | message.chat.id, 246 | texts.language_is_set.format(message.text), 247 | reply_markup=keyboards.empty, 248 | ) 249 | 250 | 251 | # add words 252 | 253 | 254 | # TODO: not allow any special characters apart from "-" 255 | @logged_execution 256 | def handle_add_words(message, bot, pool): 257 | db_model.log_command(pool, message.chat.id, message.text) 258 | language = db_model.get_current_language(pool, message.chat.id) 259 | if language is None: 260 | utils.handle_language_not_set(message, bot) 261 | return 262 | 263 | bot.set_state( 264 | message.from_user.id, states.AddWordsState.choose_mode, message.chat.id 265 | ) 266 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 267 | data["language"] = language 268 | 269 | markup = keyboards.get_reply_keyboard(options.add_words_modes, ["/cancel"]) 270 | bot.send_message(message.chat.id, texts.add_words_choose_mode, reply_markup=markup) 271 | # bot.reply_to(message, texts.add_words_instruction_one_by_one_1) 272 | 273 | 274 | @logged_execution 275 | def process_add_words_mode(message, bot, pool): 276 | if message.text not in options.add_words_modes: 277 | markup = keyboards.get_reply_keyboard(options.add_words_modes, ["/cancel"]) 278 | bot.reply_to(message, texts.add_words_choose_mode, reply_markup=markup) 279 | return 280 | 281 | if message.text == "one-by-one": 282 | bot.set_state( 283 | message.from_user.id, 284 | states.AddWordsState.add_words_one_by_one, 285 | message.chat.id, 286 | ) 287 | bot.send_message(message.chat.id, texts.add_words_instruction_one_by_one_1) 288 | elif message.text == "together": 289 | bot.set_state( 290 | message.from_user.id, 291 | states.AddWordsState.add_words_together, 292 | message.chat.id, 293 | ) 294 | bot.send_message(message.chat.id, texts.add_words_together_instruction) 295 | 296 | 297 | @logged_execution 298 | def process_adding_words_together(message, bot, pool): 299 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 300 | language = data["language"] 301 | 302 | words_with_translations = list( 303 | filter( 304 | lambda x: len(x) > 0, [w.strip().lower() for w in message.text.split("\n")] 305 | ) 306 | ) 307 | if len(words_with_translations) == 0: 308 | bot.delete_state(message.from_user.id, message.chat.id) 309 | bot.reply_to(message, texts.add_words_none_added) 310 | return 311 | 312 | words = [] 313 | translations = [] 314 | for entry in words_with_translations: 315 | if len(entry.split("=")) != 2: 316 | bot.send_message( 317 | message.chat.id, texts.add_words_together_wrong_format.format(entry) 318 | ) 319 | return 320 | if len(entry.split("=")[0].strip()) == 0: 321 | bot.send_message( 322 | message.chat.id, texts.add_words_together_empty_word.format(entry) 323 | ) 324 | return 325 | if len(entry.split("=")[1].strip()) == 0: 326 | bot.send_message( 327 | message.chat.id, 328 | texts.add_words_together_empty_translation.format(entry), 329 | ) 330 | return 331 | 332 | words.append(entry.split("=")[0].strip().lower()) 333 | translations.append( 334 | json.dumps([m.strip().lower() for m in entry.split("=")[1].split("/")]) 335 | ) 336 | 337 | db_model.update_vocab( 338 | pool, 339 | message.chat.id, 340 | language, 341 | words, 342 | translations, 343 | ) 344 | bot.delete_state(message.from_user.id, message.chat.id) 345 | bot.send_message( 346 | message.chat.id, 347 | texts.add_words_finished.format(len(words)), 348 | reply_markup=keyboards.empty, 349 | ) 350 | 351 | 352 | @logged_execution 353 | def process_adding_words_one_by_one(message, bot, pool): 354 | words = list( 355 | filter( 356 | lambda x: len(x) > 0, [w.strip().lower() for w in message.text.split("\n")] 357 | ) 358 | ) 359 | if len(words) == 0: 360 | bot.delete_state(message.from_user.id, message.chat.id) 361 | bot.reply_to(message, texts.add_words_none_added) 362 | return 363 | 364 | bot.reply_to(message, texts.add_words_instruction_one_by_one_2.format(len(words))) 365 | bot.send_message( 366 | message.chat.id, 367 | texts.add_words_translate.format(words[0]), 368 | reply_markup=keyboards.empty, 369 | ) 370 | 371 | bot.set_state( 372 | message.from_user.id, states.AddWordsState.translate_one_by_one, message.chat.id 373 | ) 374 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 375 | data["words"] = words 376 | data["translations"] = [] 377 | 378 | 379 | @logged_execution 380 | def process_word_translation_stop(message, bot, pool): 381 | bot.delete_state(message.from_user.id, message.chat.id) 382 | bot.send_message( 383 | message.chat.id, texts.add_words_cancelled, reply_markup=keyboards.empty 384 | ) 385 | 386 | 387 | @logged_execution 388 | def process_word_translation_one_by_one(message, bot, pool): 389 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 390 | data["translations"].append( 391 | json.dumps([m.strip().lower() for m in message.text.split("/")]) 392 | ) 393 | 394 | if len(data["translations"]) == len(data["words"]): # translation is over 395 | db_model.update_vocab( 396 | pool, 397 | message.chat.id, 398 | data["language"], 399 | data["words"], 400 | data["translations"], 401 | ) 402 | bot.delete_state(message.from_user.id, message.chat.id) 403 | bot.send_message( 404 | message.chat.id, 405 | texts.add_words_finished.format(len(data["words"])), 406 | reply_markup=keyboards.empty, 407 | ) 408 | else: 409 | bot.send_message( 410 | message.chat.id, 411 | data["words"][len(data["translations"])], 412 | reply_markup=keyboards.empty, 413 | ) 414 | 415 | 416 | # SHOW WORDS 417 | 418 | 419 | # TODO: delete all unnecessary messages 420 | @logged_execution 421 | def handle_show_words(message, bot, pool): 422 | db_model.log_command(pool, message.chat.id, message.text) 423 | language = db_model.get_current_language(pool, message.chat.id) 424 | if language is None: 425 | utils.handle_language_not_set(message, bot) 426 | return 427 | 428 | vocab = db_model.get_full_vocab(pool, message.chat.id, language) 429 | if len(vocab) == 0: 430 | bot.send_message( 431 | message.chat.id, texts.no_words_yet, reply_markup=keyboards.empty 432 | ) 433 | return 434 | 435 | bot.send_message( 436 | message.chat.id, 437 | texts.words_count.format(len(vocab), language), 438 | reply_markup=keyboards.empty, 439 | ) 440 | 441 | for entry in vocab: 442 | word = word_utils.Word(entry) 443 | entry["score"] = word.get_overall_score() 444 | entry["n_trains"] = word.get_total_trains() 445 | 446 | # TODO: make all keyboards one time 447 | markup = keyboards.get_reply_keyboard( 448 | options.show_words_sort_options, ["/exit"], row_width=3 449 | ) 450 | bot.send_message(message.chat.id, texts.choose_sorting, reply_markup=markup) 451 | 452 | bot.set_state( 453 | message.from_user.id, states.ShowWordsState.choose_sort, message.chat.id 454 | ) 455 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 456 | data["vocabulary"] = vocab 457 | data["original_command"] = message.text 458 | 459 | 460 | @logged_execution 461 | def process_choose_word_sort(message, bot, pool): 462 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 463 | if message.text not in options.show_words_sort_options: 464 | bot.delete_state(message.from_user.id, message.chat.id) 465 | bot.reply_to( 466 | message, texts.sorting_not_supported.format(data["original_command"]) 467 | ) 468 | return 469 | 470 | words = data["vocabulary"] 471 | 472 | # TODO: get rid of string constants 473 | if message.text == "a-z": 474 | words = sorted(words, key=lambda w: w["word"]) 475 | elif message.text == "z-a": 476 | words = sorted(words, key=lambda w: w["word"])[::-1] 477 | elif message.text == "n trains ⬇️": 478 | words = sorted(words, key=lambda w: w["n_trains"])[::-1] 479 | elif message.text == "n trains ⬆️": 480 | words = sorted(words, key=lambda w: w["n_trains"]) 481 | elif message.text == "time added ⬇️": 482 | words = sorted(words, key=lambda w: w["added_timestamp"])[::-1] 483 | elif message.text == "time added ⬆️": 484 | words = sorted(words, key=lambda w: w["added_timestamp"]) 485 | elif message.text == "score ⬇️": 486 | unknown_score = list(filter(lambda w: w["score"] is None, words)) 487 | known_score = list(filter(lambda w: w["score"] is not None, words)) 488 | words = sorted(known_score, key=lambda w: w["score"])[::-1] 489 | words.extend(unknown_score) 490 | elif message.text == "score ⬆️": 491 | unknown_score = list(filter(lambda w: w["score"] is None, words)) 492 | known_score = list(filter(lambda w: w["score"] is not None, words)) 493 | words = sorted(known_score, key=lambda w: w["score"]) 494 | words.extend(unknown_score) 495 | 496 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 497 | data["vocabulary"] = words 498 | data["batch_number"] = 0 499 | bot.set_state( 500 | message.from_user.id, states.ShowWordsState.show_words, message.chat.id 501 | ) 502 | process_show_words_batch_next(message, bot, pool) 503 | 504 | 505 | @logged_execution 506 | def process_show_words_batch_unknown(message, bot, pool): 507 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 508 | original_command = data["original_command"] 509 | bot.delete_state(message.from_user.id, message.chat.id) 510 | bot.send_message( 511 | message.chat.id, 512 | texts.unknown_command.format(original_command), 513 | reply_markup=keyboards.empty, 514 | ) 515 | 516 | 517 | @logged_execution 518 | def process_show_words_batch_next(message, bot, pool): 519 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 520 | batch_number = data["batch_number"] 521 | words = data["vocabulary"] 522 | 523 | words_batch = words[ 524 | batch_number 525 | * constants.SHOW_WORDS_BATCH_SIZE : (batch_number + 1) 526 | * constants.SHOW_WORDS_BATCH_SIZE 527 | ] 528 | words_formatted = [word_utils.format_word_for_listing(word) for word in words_batch] 529 | 530 | n_pages = len(words) // constants.SHOW_WORDS_BATCH_SIZE 531 | if len(words) % constants.SHOW_WORDS_BATCH_SIZE > 0: 532 | n_pages += 1 533 | 534 | if len(words_batch) < constants.SHOW_WORDS_BATCH_SIZE: 535 | # we've run out of words 536 | markup = keyboards.empty 537 | bot.delete_state(message.from_user.id, message.chat.id) 538 | else: 539 | markup = keyboards.get_reply_keyboard(["/exit", "/next"]) 540 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 541 | data["batch_number"] += 1 542 | 543 | bot.send_message( 544 | message.chat.id, 545 | texts.word_formatted.format( 546 | batch_number + 1, n_pages, "\n".join(words_formatted) 547 | ), 548 | reply_markup=markup, 549 | parse_mode="MarkdownV2", 550 | ) 551 | 552 | 553 | # show language info 554 | 555 | 556 | @logged_execution 557 | def handle_show_current_language(message, bot, pool): 558 | db_model.log_command(pool, message.chat.id, message.text) 559 | current_language = db_model.get_current_language(pool, message.chat.id) 560 | if current_language is not None: 561 | bot.send_message( 562 | message.chat.id, texts.current_language.format(current_language) 563 | ) 564 | else: 565 | utils.handle_language_not_set(message, bot) 566 | 567 | 568 | @logged_execution 569 | def handle_show_languages(message, bot, pool): 570 | db_model.log_command(pool, message.chat.id, message.text) 571 | languages = sorted(db_model.get_available_languages(pool, message.chat.id)) 572 | current_language = db_model.get_current_language(pool, message.chat.id) 573 | 574 | if len(languages) == 0: 575 | bot.send_message( 576 | message.chat.id, texts.show_languages_none, reply_markup=keyboards.empty 577 | ) 578 | else: 579 | languages = [ 580 | options.show_languages_mark_current[l == current_language].format(l) 581 | for l in languages 582 | ] 583 | bot.send_message( 584 | message.chat.id, 585 | texts.available_languages.format(len(languages), "\n".join(languages)), 586 | reply_markup=keyboards.empty, 587 | ) 588 | 589 | 590 | # delete language 591 | 592 | 593 | @logged_execution 594 | def handle_delete_language(message, bot, pool): 595 | db_model.log_command(pool, message.chat.id, message.text) 596 | language = db_model.get_current_language(pool, message.chat.id) 597 | if language is None: 598 | utils.handle_language_not_set(message, bot) 599 | return 600 | 601 | markup = keyboards.get_reply_keyboard(options.delete_are_you_sure) 602 | bot.send_message( 603 | message.chat.id, 604 | texts.delete_language_warning.format(language), 605 | reply_markup=markup, 606 | ) 607 | bot.set_state( 608 | message.from_user.id, states.DeleteLanguageState.init, message.chat.id 609 | ) 610 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 611 | data["language"] = language 612 | 613 | 614 | @logged_execution 615 | def process_delete_language(message, bot, pool): 616 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 617 | language = data["language"] 618 | 619 | if message.text not in options.delete_are_you_sure: 620 | bot.reply_to(message, texts.unknown_command_short) 621 | return 622 | 623 | bot.delete_state(message.from_user.id, message.chat.id) 624 | 625 | if options.delete_are_you_sure[message.text]: 626 | db_model.delete_language(pool, message.chat.id, language) 627 | bot.send_message( 628 | message.chat.id, 629 | texts.delete_language_final.format(language), 630 | reply_markup=keyboards.empty, 631 | ) 632 | else: 633 | bot.send_message( 634 | message.chat.id, texts.cancel_short, reply_markup=keyboards.empty 635 | ) 636 | 637 | 638 | # delete words 639 | 640 | 641 | # TODO: delete words from groups too 642 | @logged_execution 643 | def handle_delete_words(message, bot, pool): 644 | db_model.log_command(pool, message.chat.id, message.text) 645 | language = db_model.get_current_language(pool, message.chat.id) 646 | if language is None: 647 | utils.handle_language_not_set(message, bot) 648 | return 649 | 650 | bot.send_message(message.chat.id, texts.delete_words_start) 651 | bot.set_state(message.from_user.id, states.DeleteWordsState.init, message.chat.id) 652 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 653 | data["language"] = language 654 | 655 | 656 | @logged_execution 657 | def process_deleting_words(message, bot, pool): 658 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 659 | language = data["language"] 660 | bot.delete_state(message.from_user.id, message.chat.id) 661 | 662 | words = message.text.split("\n") 663 | existing_words = db_model.get_words_from_vocab( 664 | pool, message.chat.id, language, words 665 | ) 666 | db_model.delete_words_from_vocab(pool, message.chat.id, language, words) 667 | 668 | bot.send_message( 669 | message.chat.id, 670 | texts.deleted_words_list.format( 671 | len(existing_words), 672 | "\n".join([entry["word"] for entry in existing_words]), 673 | "" if len(existing_words) == len(words) else texts.deleted_words_unknown, 674 | ), 675 | ) 676 | 677 | 678 | # create group 679 | 680 | 681 | @logged_execution 682 | def handle_create_group(message, bot, pool): 683 | db_model.log_command(pool, message.chat.id, message.text) 684 | language = db_model.get_current_language(pool, message.chat.id) 685 | if language is None: 686 | utils.handle_language_not_set(message, bot) 687 | return 688 | 689 | bot.send_message(message.chat.id, texts.create_group_name) 690 | bot.set_state(message.from_user.id, states.CreateGroupState.init, message.chat.id) 691 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 692 | data["language"] = language 693 | 694 | 695 | @logged_execution 696 | def process_group_creation(message, bot, pool): 697 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 698 | language = data["language"] 699 | 700 | # TODO: check name collisions with shared groups 701 | group_name = message.text.strip() 702 | if not utils.check_group_name(group_name): 703 | bot.send_message(message.chat.id, texts.group_name_invalid) 704 | return 705 | 706 | bot.delete_state(message.from_user.id, message.chat.id) 707 | if len(db_model.get_group_by_name(pool, message.chat.id, language, group_name)) > 0: 708 | bot.reply_to(message, texts.group_already_exists) 709 | return 710 | 711 | group_id = blake2b(digest_size=10) 712 | group_key = "{}-{}-{}".format(message.chat.id, language, group_name) 713 | group_id.update(group_key.encode()) 714 | 715 | db_model.add_group( 716 | pool, 717 | message.chat.id, 718 | language=language, 719 | group_name=group_name, 720 | group_id=group_id.hexdigest(), 721 | is_creator=True, 722 | ) 723 | bot.send_message(message.chat.id, texts.group_created) 724 | 725 | 726 | # show groups 727 | 728 | 729 | @logged_execution 730 | def handle_show_groups(message, bot, pool): 731 | db_model.log_command(pool, message.chat.id, message.text) 732 | utils.suggest_group_choices(message, bot, pool, states.ShowGroupsState.init) 733 | 734 | 735 | @logged_execution 736 | def process_show_group_contents(message, bot, pool): 737 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 738 | language = data["language"] 739 | group_names = data["group_names"] 740 | 741 | groups = db_model.get_group_by_name(pool, message.chat.id, language, message.text) 742 | markup = keyboards.get_reply_keyboard(group_names, ["/exit"], row_width=3) 743 | 744 | if len(groups) != 1: 745 | bot.reply_to(message, texts.no_such_group, reply_markup=markup) 746 | return 747 | 748 | group_id = groups[0]["group_id"].decode("utf-8") 749 | group_contents = sorted( 750 | db_model.get_group_contents(pool, group_id), key=lambda w: w["word"] 751 | ) 752 | 753 | for entry in group_contents: 754 | word = word_utils.Word(entry) 755 | entry["score"] = word.get_overall_score() 756 | entry["n_trains"] = word.get_total_trains() 757 | 758 | if len(group_contents) == 0: 759 | bot.delete_state(message.from_user.id, message.chat.id) 760 | bot.reply_to(message, texts.show_group_empty, reply_markup=keyboards.empty) 761 | return 762 | 763 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 764 | data["batch_number"] = 0 765 | data["vocabulary"] = group_contents 766 | 767 | bot.set_state( 768 | message.from_user.id, states.ShowWordsState.show_words, message.chat.id 769 | ) 770 | process_show_words_batch_next(message, bot, pool) 771 | 772 | 773 | # group add words 774 | # TODO: delete all unnecessary messages 775 | @logged_execution 776 | def handle_group_add_words(message, bot, pool): 777 | db_model.log_command(pool, message.chat.id, message.text) 778 | utils.suggest_group_choices( 779 | message, bot, pool, states.AddGroupWordsState.choose_group 780 | ) 781 | 782 | 783 | @logged_execution 784 | def handle_choose_group_to_add_words(message, bot, pool): 785 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 786 | language = data["language"] 787 | group_names = data["group_names"] 788 | 789 | groups = db_model.get_group_by_name(pool, message.chat.id, language, message.text) 790 | markup = keyboards.get_reply_keyboard(group_names, ["/exit"], row_width=3) 791 | 792 | if len(groups) != 1: 793 | bot.reply_to(message, texts.no_such_group, reply_markup=markup) 794 | return 795 | 796 | if not groups[0]["is_creator"]: 797 | bot.delete_state(message.from_user.id, message.chat.id) 798 | bot.reply_to(message, texts.group_not_a_creator, reply_markup=keyboards.empty) 799 | return 800 | 801 | group_id = groups[0]["group_id"].decode("utf-8") 802 | vocabulary = db_model.get_full_vocab(pool, message.chat.id, language) 803 | words_in_group = set( 804 | [entry["word"] for entry in db_model.get_group_contents(pool, group_id)] 805 | ) 806 | 807 | words_to_add = [] 808 | for entry in vocabulary: 809 | if entry["word"] in words_in_group: 810 | continue 811 | words_to_add.append(entry) 812 | 813 | if len(words_to_add) == 0: 814 | bot.delete_state(message.from_user.id, message.chat.id) 815 | bot.reply_to(message, texts.group_edit_full) 816 | return 817 | 818 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 819 | data["vocabulary"] = words_to_add 820 | data["masks"] = [0] * len(words_to_add) 821 | data["batch_number"] = 0 822 | data["is_start"] = True 823 | data["batch_size"] = constants.GROUP_ADD_WORDS_BATCH_SIZE 824 | data["group_id"] = group_id 825 | data["group_name"] = message.text 826 | data["action"] = "add" 827 | 828 | bot.set_state( 829 | message.from_user.id, states.AddGroupWordsState.choose_sorting, message.chat.id 830 | ) 831 | bot.send_message( 832 | message.chat.id, 833 | texts.choose_sorting, 834 | reply_markup=keyboards.get_reply_keyboard( 835 | options.group_add_words_sort_options, ["/exit"] 836 | ), 837 | ) 838 | 839 | 840 | @logged_execution 841 | def process_choose_sorting_to_add_words(message, bot, pool): 842 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 843 | vocabulary = data["vocabulary"] 844 | 845 | if message.text not in options.group_add_words_sort_options: 846 | markup = keyboards.get_reply_keyboard( 847 | options.group_add_words_sort_options, ["/exit"] 848 | ) 849 | bot.reply_to(message, texts.sorting_not_supported, reply_markup=markup) 850 | return 851 | 852 | if message.text == "a-z": 853 | vocabulary = sorted(vocabulary, key=lambda x: x["translation"]) 854 | elif message.text == "time added ⬇️": 855 | vocabulary = sorted(vocabulary, key=lambda x: x["added_timestamp"])[::-1] 856 | 857 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 858 | data["vocabulary"] = vocabulary 859 | bot.set_state( 860 | message.from_user.id, states.AddGroupWordsState.choose_words, message.chat.id 861 | ) 862 | process_choose_words_batch_for_group(message, bot, pool) 863 | 864 | 865 | @logged_execution 866 | def process_save_group_edit(message, bot, pool): 867 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 868 | language = data["language"] 869 | vocabulary = data["vocabulary"] 870 | masks = data["masks"] 871 | group_id = data["group_id"] 872 | group_name = data["group_name"] 873 | action = data["action"] 874 | 875 | bot.delete_state(message.from_user.id, message.chat.id) 876 | 877 | chosen_words = [] 878 | for entity, mask in zip(vocabulary, masks): 879 | if action == "add" and mask == 1: 880 | chosen_words.append(entity["word"]) 881 | if action == "delete" and mask == 0: 882 | chosen_words.append(entity["word"]) 883 | n_edited_words = utils.save_words_edit_to_group( 884 | pool, message.chat.id, language, group_id, chosen_words, action 885 | ) 886 | logger.debug(f"n_edited_words: {n_edited_words}") 887 | 888 | bot.reply_to( 889 | message, 890 | texts.group_edit_finished.format( 891 | group_name, action, n_edited_words, "\n".join(sorted(list(chosen_words))) 892 | ), 893 | reply_markup=keyboards.empty, 894 | ) 895 | 896 | 897 | @logged_execution 898 | def process_choose_words_batch_for_group_next(message, bot, pool): 899 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 900 | batch_number = data["batch_number"] 901 | batch_size = data["batch_size"] 902 | vocabulary = data["vocabulary"] 903 | 904 | if batch_number * batch_size > len(vocabulary): 905 | # the words have ended 906 | bot.send_message( 907 | message.chat.id, 908 | texts.group_edit_no_more_words, 909 | reply_keyboard=keyboards.empty, 910 | ) 911 | process_save_group_edit(message, bot, pool) 912 | return 913 | 914 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 915 | data["batch_number"] += 1 916 | data["is_start"] = True 917 | process_choose_words_batch_for_group(message, bot, pool) 918 | 919 | 920 | @logged_execution 921 | def process_choose_words_batch_for_group(message, bot, pool): 922 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 923 | vocabulary = data["vocabulary"] 924 | masks = data["masks"] 925 | batch_number = data["batch_number"] 926 | is_start = data["is_start"] 927 | batch_size = data["batch_size"] 928 | action = data["action"] 929 | group_name = data["group_name"] 930 | 931 | n_batches = utils.get_number_of_batches( 932 | constants.GROUP_ADD_WORDS_BATCH_SIZE, len(vocabulary) 933 | ) 934 | 935 | batch = vocabulary[batch_number * batch_size : (batch_number + 1) * batch_size] 936 | batch_mask = masks[batch_number * batch_size : (batch_number + 1) * batch_size] 937 | 938 | additional_commands = ["/cancel", "/exit"] 939 | logger.debug(f"batch_number: {batch_number}, n_batches: {n_batches}") 940 | if batch_number + 1 < n_batches: 941 | additional_commands.append("/next") 942 | 943 | markup = keyboards.get_masked_choices( 944 | batch, batch_mask, additional_commands=additional_commands 945 | ) 946 | 947 | if is_start: 948 | bot.send_message( 949 | message.chat.id, 950 | texts.group_edit_choose.format( 951 | action, group_name, batch_number + 1, n_batches 952 | ), 953 | reply_markup=markup, 954 | ) 955 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 956 | data["is_start"] = False 957 | elif word_utils.get_word_from_group_action(message.text) not in [ 958 | entry["word"] for entry in batch 959 | ]: 960 | logger.debug( 961 | f"word clicked: {word_utils.get_word_from_group_action(message.text)}" 962 | ) 963 | bot.send_message( 964 | message.chat.id, texts.group_edit_unknown_word, reply_markup=markup 965 | ) 966 | else: 967 | current_word = word_utils.get_word_from_group_action(message.text) 968 | word_idx = word_utils.get_word_idx(batch, current_word) 969 | batch_mask[word_idx] = (batch_mask[word_idx] + 1) % 2 970 | 971 | markup = keyboards.get_masked_choices( 972 | batch, batch_mask, additional_commands=additional_commands 973 | ) 974 | bot.send_message(message.chat.id, texts.group_edit_confirm, reply_markup=markup) 975 | 976 | masks[ 977 | batch_number * constants.GROUP_ADD_WORDS_BATCH_SIZE + word_idx 978 | ] = batch_mask[word_idx] 979 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 980 | data["masks"] = masks 981 | 982 | 983 | # group delete words 984 | @logged_execution 985 | def handle_group_delete_words(message, bot, pool): 986 | db_model.log_command(pool, message.chat.id, message.text) 987 | utils.suggest_group_choices( 988 | message, bot, pool, states.DeleteGroupWordsState.choose_group 989 | ) 990 | 991 | 992 | @logged_execution 993 | def handle_choose_group_to_delete_words(message, bot, pool): 994 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 995 | language = data["language"] 996 | group_names = data["group_names"] 997 | 998 | groups = db_model.get_group_by_name(pool, message.chat.id, language, message.text) 999 | markup = keyboards.get_reply_keyboard(group_names, ["/exit"], row_width=3) 1000 | 1001 | if len(groups) != 1: 1002 | bot.reply_to(message, texts.no_such_group, reply_markup=markup) 1003 | return 1004 | 1005 | if not groups[0]["is_creator"]: 1006 | bot.delete_state(message.from_user.id, message.chat.id) 1007 | bot.reply_to(message, texts.group_not_a_creator, reply_markup=keyboards.empty) 1008 | return 1009 | 1010 | group_id = groups[0]["group_id"].decode("utf-8") 1011 | words_in_group = db_model.get_group_contents(pool, group_id) 1012 | 1013 | if len(words_in_group) == 0: 1014 | bot.delete_state(message.from_user.id, message.chat.id) 1015 | bot.reply_to(message, texts.group_edit_empty) 1016 | return 1017 | 1018 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1019 | data["vocabulary"] = words_in_group 1020 | data["masks"] = [1] * len(words_in_group) 1021 | data["batch_number"] = 0 1022 | data["is_start"] = True 1023 | data["batch_size"] = constants.GROUP_ADD_WORDS_BATCH_SIZE 1024 | data["group_id"] = group_id 1025 | data["group_name"] = message.text 1026 | data["action"] = "delete" 1027 | 1028 | bot.set_state( 1029 | message.from_user.id, 1030 | states.DeleteGroupWordsState.choose_sorting, 1031 | message.chat.id, 1032 | ) 1033 | bot.send_message( 1034 | message.chat.id, 1035 | texts.choose_sorting, 1036 | reply_markup=keyboards.get_reply_keyboard( 1037 | options.group_add_words_sort_options, ["/exit"] 1038 | ), 1039 | ) 1040 | 1041 | 1042 | # TODO: merge with process_choose_sorting_to_add_words, only state is different 1043 | @logged_execution 1044 | def process_choose_sorting_to_delete_words(message, bot, pool): 1045 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1046 | vocabulary = data["vocabulary"] 1047 | 1048 | if message.text not in options.group_add_words_sort_options: 1049 | markup = keyboards.get_reply_keyboard( 1050 | options.group_add_words_sort_options, ["/exit"] 1051 | ) 1052 | bot.reply_to(message, texts.sorting_not_supported, reply_markup=markup) 1053 | return 1054 | 1055 | if message.text == "a-z": 1056 | vocabulary = sorted(vocabulary, key=lambda x: x["translation"]) 1057 | elif message.text == "time added ⬇️": 1058 | vocabulary = sorted(vocabulary, key=lambda x: x["added_timestamp"])[::-1] 1059 | 1060 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1061 | data["vocabulary"] = vocabulary 1062 | bot.set_state( 1063 | message.from_user.id, states.DeleteGroupWordsState.choose_words, message.chat.id 1064 | ) 1065 | process_choose_words_batch_for_group(message, bot, pool) 1066 | 1067 | 1068 | # delete group 1069 | 1070 | 1071 | @logged_execution 1072 | def handle_delete_group(message, bot, pool): 1073 | db_model.log_command(pool, message.chat.id, message.text) 1074 | utils.suggest_group_choices( 1075 | message, bot, pool, states.DeleteGroupState.select_group 1076 | ) 1077 | 1078 | 1079 | @logged_execution 1080 | def process_group_deletion_check_sure(message, bot, pool): 1081 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1082 | language = data["language"] 1083 | 1084 | groups = db_model.get_group_by_name(pool, message.chat.id, language, message.text) 1085 | 1086 | if len(groups) == 0: 1087 | bot.reply_to(message, texts.no_such_group) 1088 | utils.suggest_group_choices( 1089 | message, bot, pool, states.DeleteGroupState.select_group 1090 | ) 1091 | return 1092 | 1093 | group_id = groups[0]["group_id"].decode("utf-8") 1094 | group_name = groups[0]["group_name"].decode("utf-8") 1095 | is_creator = groups[0]["is_creator"] 1096 | 1097 | if not groups[0]["is_creator"]: 1098 | bot.delete_state(message.from_user.id, message.chat.id) 1099 | bot.reply_to(message, texts.group_not_a_creator, reply_markup=keyboards.empty) 1100 | return 1101 | 1102 | bot.set_state( 1103 | message.from_user.id, states.DeleteGroupState.are_you_sure, message.chat.id 1104 | ) 1105 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1106 | data["group_name"] = group_name 1107 | data["group_id"] = group_id 1108 | data["is_creator"] = is_creator 1109 | 1110 | markup = keyboards.get_reply_keyboard(options.delete_are_you_sure) 1111 | bot.send_message( 1112 | message.chat.id, 1113 | texts.delete_group_warning.format(group_name, language), 1114 | reply_markup=markup, 1115 | ) 1116 | 1117 | 1118 | @logged_execution 1119 | def process_group_deletion(message, bot, pool): 1120 | # TODO: when sharing think of local / global deletions (use is_creator) 1121 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1122 | group_name = data["group_name"] 1123 | group_id = data["group_id"] 1124 | # is_creator = data["is_creator"] 1125 | 1126 | if message.text not in options.delete_are_you_sure: 1127 | markup = keyboards.get_reply_keyboard(options.delete_are_you_sure) 1128 | bot.reply_to(message, texts.unknown_command_short, reply_markup=markup) 1129 | return 1130 | 1131 | bot.delete_state(message.from_user.id, message.chat.id) 1132 | 1133 | if not options.delete_are_you_sure[message.text]: 1134 | bot.send_message( 1135 | message.chat.id, texts.delete_group_cancel, reply_markup=keyboards.empty 1136 | ) 1137 | return 1138 | 1139 | db_model.delete_group(pool, group_id) 1140 | bot.send_message(message.chat.id, texts.delete_group_success.format(group_name)) 1141 | 1142 | 1143 | # TRAIN 1144 | 1145 | 1146 | @logged_execution 1147 | def handle_train(message, bot, pool): 1148 | db_model.log_command(pool, message.chat.id, message.text) 1149 | language = db_model.get_current_language(pool, message.chat.id) 1150 | if language is None: 1151 | utils.handle_language_not_set(message, bot) 1152 | return 1153 | 1154 | if len(db_model.get_full_vocab(pool, message.chat.id, language)) == 0: 1155 | bot.send_message( 1156 | message.chat.id, texts.training_no_words, reply_markup=keyboards.empty 1157 | ) 1158 | return 1159 | 1160 | markup = keyboards.get_reply_keyboard(options.train_strategy_options, ["/cancel"]) 1161 | reply_message = bot.send_message( 1162 | message.chat.id, texts.training_init, reply_markup=markup 1163 | ) 1164 | 1165 | bot.set_state( 1166 | message.from_user.id, states.TrainState.choose_strategy, message.chat.id 1167 | ) 1168 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1169 | data["language"] = language 1170 | data["init_message"] = reply_message.id 1171 | 1172 | 1173 | @logged_execution 1174 | def init_direction_choice(message, bot, pool): 1175 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1176 | language = data["language"] 1177 | 1178 | markup = keyboards.get_reply_keyboard(options.train_direction_options, ["/cancel"]) 1179 | bot.send_message( 1180 | message.chat.id, 1181 | texts.training_direction.format( 1182 | language.split("->")[0], 1183 | language.split("->")[1], 1184 | language.split("->")[1], 1185 | language.split("->")[0], 1186 | ), 1187 | reply_markup=markup, 1188 | ) 1189 | bot.set_state( 1190 | message.from_user.id, states.TrainState.choose_direction, message.chat.id 1191 | ) 1192 | 1193 | 1194 | @logged_execution 1195 | def process_choose_strategy(message, bot, pool): 1196 | if message.text not in options.train_strategy_options: 1197 | markup = keyboards.get_reply_keyboard( 1198 | options.train_strategy_options, ["/cancel"] 1199 | ) 1200 | bot.reply_to(message, texts.training_strategy_unknown, reply_markup=markup) 1201 | return 1202 | 1203 | if message.text == "group": 1204 | utils.suggest_group_choices(message, bot, pool, states.TrainState.choose_group) 1205 | else: 1206 | # bot.set_state(message.from_user.id, states.TrainState.choose_direction, message.chat.id) 1207 | init_direction_choice(message, bot, pool) 1208 | 1209 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1210 | data["strategy"] = message.text 1211 | 1212 | 1213 | @logged_execution 1214 | def process_choose_group_for_training(message, bot, pool): 1215 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1216 | language = data["language"] 1217 | 1218 | groups = db_model.get_group_by_name(pool, message.chat.id, language, message.text) 1219 | 1220 | if len(groups) == 0: 1221 | bot.reply_to( 1222 | message, texts.no_such_group.format("/train"), reply_markup=keyboards.empty 1223 | ) 1224 | utils.suggest_group_choices(message, bot, pool, states.TrainState.choose_group) 1225 | return 1226 | 1227 | group_id = groups[0]["group_id"].decode("utf-8") 1228 | group_name = message.text 1229 | init_direction_choice(message, bot, pool) 1230 | 1231 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1232 | data["group_id"] = group_id 1233 | data["group_name"] = group_name 1234 | 1235 | 1236 | @logged_execution 1237 | def process_choose_direction(message, bot, pool): 1238 | if message.text not in options.train_direction_options.keys(): 1239 | markup = keyboards.get_reply_keyboard( 1240 | options.train_direction_options, ["/cancel"] 1241 | ) 1242 | bot.reply_to(message, texts.training_direction_unknown, reply_markup=markup) 1243 | return 1244 | 1245 | bot.set_state( 1246 | message.from_user.id, states.TrainState.choose_duration, message.chat.id 1247 | ) 1248 | markup = keyboards.get_reply_keyboard(options.train_duration_options, ["/cancel"]) 1249 | bot.send_message(message.chat.id, texts.training_duration, reply_markup=markup) 1250 | 1251 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1252 | data["direction"] = options.train_direction_options[message.text] 1253 | 1254 | 1255 | @logged_execution 1256 | def process_choose_duration(message, bot, pool): 1257 | if ( 1258 | not message.text.isdigit() 1259 | and message.text not in options.train_duration_options 1260 | ): 1261 | markup = keyboards.get_reply_keyboard( 1262 | options.train_duration_options, ["/cancel"] 1263 | ) 1264 | bot.reply_to(message, texts.training_duration_unknown, reply_markup=markup) 1265 | return 1266 | 1267 | bot.set_state(message.from_user.id, states.TrainState.choose_hints, message.chat.id) 1268 | markup = keyboards.get_reply_keyboard(options.train_hints_options, ["/cancel"]) 1269 | bot.send_message(message.chat.id, texts.training_hints, reply_markup=markup) 1270 | 1271 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1272 | data["duration"] = ( 1273 | int(message.text) if message.text.isdigit() else config.TRAIN_MAX_N_WORDS 1274 | ) 1275 | 1276 | 1277 | @logged_execution 1278 | def process_choose_hints(message, bot, pool): 1279 | if message.text not in options.train_hints_options: 1280 | markup = keyboards.get_reply_keyboard(options.train_hints_options, ["/cancel"]) 1281 | bot.reply_to(message, texts.training_hints_unknown, reply_markup=markup) 1282 | return 1283 | 1284 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1285 | data["hints"] = message.text 1286 | data["session_id"] = db_model.get_current_time() 1287 | 1288 | language = data["language"] 1289 | strategy = data["strategy"] 1290 | direction = data["direction"] 1291 | duration = data["duration"] 1292 | hints = data["hints"] 1293 | session_id = data["session_id"] 1294 | group_id = data.get("group_id") 1295 | group_name = data.get("group_name") 1296 | init_message = data["init_message"] 1297 | 1298 | db_model.init_training_session( 1299 | pool, 1300 | message.chat.id, 1301 | session_id, 1302 | strategy, 1303 | language, 1304 | direction, 1305 | duration, 1306 | hints, 1307 | ) 1308 | if strategy != "group": 1309 | db_model.create_training_session( 1310 | pool, message.chat.id, session_id, strategy, language, direction, duration 1311 | ) 1312 | else: 1313 | db_model.create_group_training_session( 1314 | pool, 1315 | message.chat.id, 1316 | session_id, 1317 | strategy, 1318 | language, 1319 | direction, 1320 | duration, 1321 | group_id, 1322 | ) 1323 | 1324 | words = db_model.get_training_words(pool, message.chat.id, session_id) 1325 | 1326 | if len(words) == 0: 1327 | bot.delete_state(message.from_user.id, message.chat.id) 1328 | bot.send_message( 1329 | message.chat.id, texts.training_no_words_found, reply_markup=keyboards.empty 1330 | ) 1331 | return 1332 | 1333 | train_message = bot.send_message( 1334 | message.chat.id, 1335 | texts.training_start.format( 1336 | strategy, 1337 | len(words), 1338 | direction, 1339 | hints, 1340 | texts.training_start_group.format(group_name) 1341 | if strategy == "group" 1342 | else "", 1343 | ), 1344 | reply_markup=keyboards.empty, 1345 | ) 1346 | if len(words) < duration and duration != config.TRAIN_MAX_N_WORDS: 1347 | bot.send_message( 1348 | message.chat.id, texts.training_fewer_words, reply_markup=keyboards.empty 1349 | ) 1350 | 1351 | utils.clear_history(bot, message.chat.id, init_message, train_message.id) 1352 | 1353 | bot.set_state(message.from_user.id, states.TrainState.train, message.chat.id) 1354 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1355 | data["words"] = words 1356 | data["step"] = 0 1357 | data["scores"] = [] 1358 | 1359 | handle_train_step(message, bot, pool) 1360 | 1361 | 1362 | @logged_execution 1363 | def handle_train_step_stop(message, bot, pool): 1364 | bot.delete_state(message.from_user.id, message.chat.id) 1365 | bot.send_message( 1366 | message.chat.id, texts.training_stopped, reply_markup=keyboards.empty 1367 | ) 1368 | 1369 | 1370 | @logged_execution 1371 | def handle_train_step(message, bot, pool): 1372 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1373 | step = data["step"] 1374 | words = data["words"] 1375 | scores = data["scores"] 1376 | hints = data["hints"] 1377 | direction = data["direction"] 1378 | session_id = data["session_id"] 1379 | language = data["language"] 1380 | 1381 | logger.debug( 1382 | f"step: {step}, words: {words}, scores: {scores}, hints: {hints}, direction: {direction}" 1383 | ) 1384 | 1385 | if step != 0: # not a first iteration 1386 | word = words[step - 1] 1387 | is_correct = word_utils.compare_user_input_with_db( 1388 | message.text, word, hints, direction 1389 | ) 1390 | scores.append(int(is_correct)) 1391 | if is_correct: 1392 | bot.send_message( 1393 | message.chat.id, 1394 | texts.train_correct_answer, 1395 | reply_markup=keyboards.empty, 1396 | ) 1397 | else: 1398 | bot.send_message( 1399 | message.chat.id, 1400 | texts.train_wrong_answer.format( 1401 | word_utils.get_translation(word, direction) 1402 | ), 1403 | reply_markup=keyboards.empty, 1404 | ) 1405 | 1406 | if step == len(words): # training complete 1407 | if hints == "no hints": 1408 | db_model.set_training_scores( 1409 | pool, 1410 | message.chat.id, 1411 | session_id, 1412 | list(range(1, len(words) + 1)), 1413 | scores, 1414 | ) 1415 | db_model.update_final_scores( 1416 | pool, message.chat.id, session_id, language, direction 1417 | ) 1418 | else: 1419 | bot.send_message(message.chat.id, texts.training_no_scores) 1420 | bot.delete_state(message.from_user.id, message.chat.id) 1421 | 1422 | reaction = None 1423 | for score, current_reaction in sorted( 1424 | options.train_reactions.items(), key=lambda x: x[0] 1425 | ): 1426 | if sum(scores) / len(words) >= score: 1427 | reaction = current_reaction 1428 | 1429 | bot.send_message( 1430 | message.chat.id, 1431 | texts.training_results.format(sum(scores), len(words), reaction), 1432 | reply_markup=keyboards.empty, 1433 | ) 1434 | return 1435 | 1436 | next_word = words[step] 1437 | hint_words = word_utils.sample_hints(next_word, words, 3) 1438 | bot.send_message( 1439 | message.chat.id, 1440 | word_utils.format_train_message( 1441 | word_utils.get_word(next_word, direction), 1442 | word_utils.get_translation(next_word, direction), 1443 | hints, 1444 | ), 1445 | reply_markup=keyboards.format_train_buttons( 1446 | word_utils.get_translation(next_word, direction), 1447 | [word_utils.get_translation(hint, direction) for hint in hint_words], 1448 | hints, 1449 | ), 1450 | parse_mode="MarkdownV2", 1451 | ) 1452 | with bot.retrieve_data(message.from_user.id, message.chat.id) as data: 1453 | data["step"] += 1 1454 | data["scores"] = scores 1455 | --------------------------------------------------------------------------------