Skip to content

Commit

Permalink
Merge pull request #2 from dan-sazonov/doc
Browse files Browse the repository at this point in the history
 Release v0.1.1-beta
  • Loading branch information
dan-sazonov committed Nov 27, 2023
2 parents d54c319 + e1469f1 commit 993109d
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 9 deletions.
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# AB Global bot
![OpenSource](https://img.shields.io/badge/Open%20Source-%E2%99%A5-red)
![GPL-3.0 license ](https://img.shields.io/github/license/dan-sazonov/ab-global-bot)
![Tested on linux, Win10](https://img.shields.io/badge/tested%20on-Linux%20|%20Win10-blue)
[![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)

**Телеграм-бот для проведения масштабных A/B тестирований названий или других текстовых данных**

> Данная версия - предрелизная. Код нуждается в серьезной доработке, более подробно - в разделе [Разработка](#Разработка)
**Стэк:**
- Python 3
- SQLite 3 + Peewee
- Aiogram 3

## 📦 Установка и запуск
В качестве пакетного менеджера и виртуального окружения используется [Poetry](https://github.com/python-poetry/poetry). Требуемая версия python - 3.11.

Склонируйте этот репозиторий, перейдите в новую директорию и установите нужные пакеты:
```
$ git clone https://github.com/dan-sazonov/ab-global-bot.git
$ cd ab-global-bot
$ poetry install
```
Настройте переменные окружения: в файл `.env.example` вставьте токен бота и телеграм-айди администратора, после чего сохраните данный файл под названием `.env`.<br>

Добавьте список тестируемых названий. Создайте файл `words.txt`, в который вставьте названия, разделив их переносом строки (каждое название на новой строке).<br>

Отредактируйте файл `messages.py`, заменив примеры на сообщения, которые должен отправлять бот в соответствии с вашей задачей.<br>

Запустите файл с точкой входа:
```
poetry run python main.py
```

Программа протестирована на Windows 10 x64 и Ubuntu 20.04 x64.

## ⚙ Использование
Бот работает следующим образом: пользователю, запустившего его, показываются два названия, которые рандомно вытаскиваются из файла `words.txt`. Задача пользователя - выбрать название, которое больше соответствует заданной цели. Показ пар будет бесконечным.<br>

Лица, проводящие A/B исследование, будут видеть статистику в таблице `words` в базе данных - в ней отображается список слов, а также показатели, сколько раз каждое слово было предложено для голосования, и сколько раз за него пользователи отдали голос.<br>

В таблице `users` отображается список пользователей, количество отданных голосов, а также время регистрации и последней активности. По этим данным можно оценивать паттерн поведения пользователя, и вероятность слепой "накрутки" голосов.<br>

## 🎯 Разработка
Код разрабатывался в соответствии с методологией "х-х и в продакшн", поэтому нуждается в рефакторинге. Ближайшие задачи:

- [ ] Разделить работу с БД, бизнес-логику, отображение, конфигурацию aiogram на отдельные модули
- [ ] Внедрить лучшие практики Aiogram 3
- [ ] Прописать type hint для функций и классов
- [ ] Улучшить логирование
- [ ] Добавить обработку возможных исключений, EAFP
- [ ] Написать тесты
- [ ] Внедрить линтер и проверку типизации, настроить CI на гитхаб
- [ ] Пересесть на Postgres

**Баги и фичи:**
- [ ] Добавить работу с изображениями
- [ ] Обрабатывать и игнорировать механическое бездумное "накручивание" количества голосов от пользователя
- [ ] Улучшить скорость отправки нового сообщения после получения голоса
- [ ] Сохранять в БД ссылку на аккаунт пользователей
- [ ] Рассылать раз в nный промежуток времени пользователям сообщение с приглашением продолжить опрос
- [ ] Сообщать пользователю, сколько раз он уже проголосовал

Если вы хотите внести свой вклад, откройте ишью, в котором опишите, над чем вы работаете и ваше видение реализации, сделайте форк репозитория, и после завершения работы предложите пулл-реквест в ветку `dev` с названием как у ишью. Для именования коммитов используйте английский язык и [Conventional Commits](https://github.com/conventional-commits/conventionalcommits.org).

## 🛠 Исходники
Весь исполняемый код бота расположен в директории `/bot`. Точка входа - файл `main.py`. В корневой директории лежат файлы, отвечающие за конфигурацию и прочее. Также в корне располагается файл `words.txt`, в котором должен находится контент для тестирования, а также файл базы данных `data.db`. Основная идея на данный момент по организации кодовой базы:
- `main.py` - точка входа, обработчики действий пользователя. Также, логика отвечающая за отображение
- `config.py` - обработка переменных окружения, объекты, используемые для хранения параметров
- `models.py` - модели ORM
- `db.py` - функционал по работе с БД, а также функции, отвечающие за обработку данных из моделей
- `messages.py` - тексты сообщений, отправляемые ботом
- `keyboards.py` - клавиатуры бота
- `services.py` - вспомогательные функции

## 👨‍💻 Автор
Автор этого репозитория, идеи и кода - [@dan-sazonov](https://github.com/dan-sazonov). <br>
**Связаться со мной:**<br>
[✈️ Telegram](https://t.me/dan_sazonov) <br>
[📧 Email](mailto:p-294803@yandex.com) <br>
42 changes: 42 additions & 0 deletions bot/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@


def _add_words() -> None:
"""
Сохраняет в бд words подготовленные модели
:return: None
"""
with db:
Word.insert_many(services.get_words_objects()).execute()


def create_tables() -> None:
"""
Создает файл с бд, заполняет таблицу со словами
:return: None
"""
has_db = prev_state

with db:
Expand All @@ -20,6 +30,13 @@ def create_tables() -> None:


def add_user(usr_id: int, usr_date: datetime = None) -> None:
"""
Добавляет юзера в бд
:param usr_id: тг-ид юзера
:param usr_date: время его регистрации в боте
:return: None
"""
usr_date = usr_date if usr_date else datetime.now()

usr_obj = User(
Expand All @@ -35,6 +52,13 @@ def add_user(usr_id: int, usr_date: datetime = None) -> None:


def update_user(usr_id: int, usr_date: datetime = None) -> None:
"""
Обновляет для юзера время активности и инкриминирует количество сообщений
:param usr_id: тг-ид юзера
:param usr_date: время последней активности. Если None, будет сохранено текущее
:return: None
"""
usr = User.get(User.tg_id == usr_id)
usr.date_act = usr_date if usr_date else datetime.now()
usr.resp_num += 1
Expand All @@ -44,6 +68,12 @@ def update_user(usr_id: int, usr_date: datetime = None) -> None:


def update_show_num(word_id: int) -> None:
"""
Обновляет счетчик показа слова
:param word_id: ид слова
:return: None
"""
word = Word.get(Word.id == word_id)
word.show_num += 1

Expand All @@ -52,6 +82,12 @@ def update_show_num(word_id: int) -> None:


def update_voted_word(voted_word_id: int) -> None:
"""
Обновляет счетчик слова, за которое проголосовал юзер
:param voted_word_id: ид слова
:return: None
"""
if not voted_word_id:
return

Expand All @@ -63,6 +99,12 @@ def update_voted_word(voted_word_id: int) -> None:


def get_words(words_ids: tuple[int, int]) -> tuple[str]:
"""
Дергает из бд два слова по их идам
:param words_ids: иды слов, кортеж
:return: кортеж самих слов
"""
out = []

for i in words_ids:
Expand Down
69 changes: 67 additions & 2 deletions bot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,13 @@
bot = Bot(config.settings.bot_token, parse_mode=ParseMode.HTML)


async def _set_commands(target: Bot):
async def _set_commands(target: Bot) -> None:
"""
Задает список команд бота, вызывается один раз при старте
:param target: основной объект бота
:return: None
"""
commands = [
BotCommand(
command='start',
Expand All @@ -37,17 +43,30 @@ async def _set_commands(target: Bot):


class Answer(StatesGroup):
"""Стэйт, в котором сохраняется строка вида 'id_первого_слова|id_второго_слова' """
prev_id = State()


def _new_pair(ids: tuple[int, int]) -> str:
"""
Готовит сообщения бота с вариантами названий
:param ids: кортеж из двух идов
:return: строка с текстом сообщения
"""
out = db.get_words(ids)
return f'{messages.VOTING_TITLE}\n' \
f'1. {hbold(out[0])}\n\n' \
f'2. {hbold(out[1])}'


def _parse_state_data(data: dict) -> list[int] | list:
"""
Парсит строку из стэйта
:param data: словарь данных стэйта
:return: лист идов названий из текущего сообщения бота, или пустой список, если это первое сообщение
"""
try:
ans = data['prev_id'].split('|')
return [int(i) for i in ans]
Expand All @@ -56,13 +75,27 @@ def _parse_state_data(data: dict) -> list[int] | list:


def _get_usr_ans(message: Message, ans: list[int]) -> int:
"""
Возвращает ид названия, за которое проголосовал пользователь
:param message: объект сообщения
:param ans: иды предложенных названий
:return: ид названия, за которое отдан голос, или ноль
"""
if message.text.isdecimal() and ans:
index = int(message.text) - 1
return ans[index]
return 0


def _update_counters(message: Message, ids: list[int, int]) -> None:
"""
Увеличивает счетчик показов для двух названий
:param message: объект сообщения, из него нужен только ид пользователя
:param ids: иды показанных пользователю слов
:return: None
"""
if not ids:
return

Expand All @@ -73,19 +106,36 @@ def _update_counters(message: Message, ids: list[int, int]) -> None:

@dp.startup()
async def on_startup():
"""
По запуску бота - создаем бд, заводим список команд бота, отправляем админу сообщение, что все ок
:return: None
"""
db.create_tables()
await _set_commands(bot)
await bot.send_message(chat_id=config.settings.admin_id, text=messages.ON_START)


@dp.shutdown()
async def on_shutdown():
"""
По остановке бота - закрываем луп асинки и пишем админу
:return: None
"""
await bot.close()
await bot.send_message(chat_id=config.settings.admin_id, text=messages.ON_STOP)


@dp.message((F.text == "1") | (F.text == "2") | (F.text == messages.KB_START_TEXT))
async def polling_handler(message: Message, state: FSMContext) -> None:
"""
Обрабатывает ответ пользователя на голосование или его начало. Вся логика голосования дергается отсюда
:param message: объект сообщения
:param state: стэйт, в нем храним ответ пользователя на предыдущее голосование
:return: None
"""
data = _parse_state_data(await state.get_data())
_update_counters(message, data)
voted_id = _get_usr_ans(message, data)
Expand All @@ -100,19 +150,34 @@ async def polling_handler(message: Message, state: FSMContext) -> None:
@dp.message(Command("start"))
async def command_start_handler(message: Message) -> None:
"""
This handler receives messages with `/start` command
Отвечает на запуск бота - пригласительное сообщение и клавиатура с текстовой кнопкой начала
:param message: объект сообщения
:return: None
"""
db.add_user(message.from_user.id, message.date)
await message.answer(messages.START, reply_markup=keyboard_start)


@dp.message(Command("help"))
async def command_help_handler(message: Message) -> None:
"""
Выводит справочное сообщение по команде /help
:param message: объект сообщения
:return: None
"""
await message.answer(messages.HELP)


@dp.message()
async def unknown_command_handler(message: Message) -> None:
"""
Обработчик непонятных боту сообщений
:param message: объект сообщения
:return: None
"""
await message.answer(messages.UNKNOWN)


Expand Down
4 changes: 2 additions & 2 deletions bot/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import config

prev_state = os.path.isfile(config.DB_FILE)
prev_state = os.path.isfile(config.DB_FILE) # флаг, существует ли файл бд. Его тащим в db.py
db = SqliteDatabase(config.DB_FILE)


Expand Down Expand Up @@ -35,4 +35,4 @@ class Meta:
db_table = 'words'


models_list = [User, Word]
models_list = [User, Word] # для удобства импорта пихаем модели в один лист
26 changes: 21 additions & 5 deletions bot/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,40 @@


def _check_data_file(path: str) -> None:
"""
Проверяет, существует ли файл с названиями. Если нет - завершает программу с кодом 1
:param path: путь к файлу
:return: None
"""
if not os.path.isfile(path):
print("Can't read data file with words")
exit(1)


def _get_words() -> list[str]:
"""
Converts a txt file with words on separate lines to a list of strings.
Empty strings would be deleted
Преобразует текстовый файл со словами в отдельных строках в список строк.
Пустая строка будет удалена
:return: list of str
:return: лист слов из файла
"""
path = '../words.txt'
_check_data_file(path)

with open(path, 'r', encoding='utf-8') as f:
file_content = f.read().split('\n')
words = list(filter(None, file_content)) # remove empty strings
words = list(filter(None, file_content)) # удалит пустые строки

return words


def get_words_objects() -> list[dict]:
"""
Преобразует список слов в модель для бд
:return: лист словарей в соответствии с моделью Word
"""
word_list = _get_words()
words = []

Expand All @@ -45,7 +56,12 @@ def get_words_objects() -> list[dict]:


def get_words_ids() -> tuple[int, int]:
max_id = Word.select(fn.MAX(Word.id)).scalar()
"""
Возвращает два случайных ида названий из бд
:return: кортеж с парой идов
"""
max_id = Word.select(fn.MAX(Word.id)).scalar() # получает значение максимального ида
id_1 = id_2 = random.randint(1, max_id)

while id_1 == id_2:
Expand Down

0 comments on commit 993109d

Please sign in to comment.