diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d92276a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__ +config.json +logs +*.wpr \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..87ce90c --- /dev/null +++ b/bot.py @@ -0,0 +1,105 @@ +from database.l9 import L9_DB +from database.tg import TG_DB +from utils.config import * +import telegram +from tg.keyboards import Keyboard +import logging +from logging.handlers import TimedRotatingFileHandler as TRFL + +logger = logging.getLogger('bot') + + +def initLogger(): + if not os.path.isdir(f'logs/bot'): + os.makedirs(f'logs/bot') + + f_handler = TRFL(f'./logs/bot/log', when='midnight', encoding="utf-8") + + f_format = logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s', + datefmt='%d-%b-%y %H:%M:%S', + ) + f_handler.setFormatter(f_format) + f_handler.setLevel(logging.INFO) + logger.addHandler(f_handler) + + c_handler = logging.StreamHandler() + c_format = logging.Formatter('%(levelname)s : %(message)s') + c_handler.setFormatter(c_format) + logger.addHandler(c_handler) + logger.setLevel(logging.DEBUG) + + +class Bot: + def __init__(self, token: str, db: L9_DB, tg_db: TG_DB, limit=150): + self.l9lk = db + self.tg_db = tg_db + self.tg = telegram.Bot(token) + self.limit = limit + self.udpate_id = None + self.isWork = True + + def start(self, query: telegram.Message): + """Обработка нового пользователя""" + + # Проверка лимита пользователей и обработка лишних + count = self.l9lk.countUsers() + tgId = query.from_user.id + + if count >= self.limit: + self.tg.sendMessage( + tgId, + ( + 'Бот работает в тестовом режиме, поэтому количество пользователей временно ограничено.\n' + 'К сожалению, в данный момент лимит превышен, поэтому доступ для вас закрыт 😢' + 'Попробуйте зайти на следующей неделе, когда лимит будет повышен' + ), + ) + + else: + self.tg_db.changeTag(tgId, 'add') + self.tg.sendMessage( + tgId, + ( + 'Привет! Я твой новый помощник, который подскажет тебе, какая сейчас пара, ' + 'и будет напоминать о занятиях, чтобы ты ничего не упустил 🤗\n' + 'Давай знакомиться! Введи свой номер группы (например, 2305 или 2305-240502D)' + ), + ) + + def checkMessages(self): + """Проверка и обработка входящих сообщений""" + + updates = self.tg.get_updates(offset=self.udpate_id, timeout=5) + for update in updates: + self.udpate_id = update.update_id + 1 + if update.message: + query = update.message + tag, l9Id, log = self.tg_db.getTag(query) + logger.info(log) + tgId = query.from_user.id + + if tag == 'not_started': + self.start(query) + + else: + self.tg.sendMessage( + tgId, + "Ой!", + reply_markup=Keyboard.menu(), + ) + + +if __name__ == "__main__": + initLogger() + logger.info("Start bot") + + config = loadJSON("config") + l9lk = L9_DB(**config['sql']) + tg_db = TG_DB(l9lk) + bot = Bot(config['tg']['token'], l9lk, tg_db, config['tg']['limit']) + + logger.info("Bot ready!") + + while bot.isWork: + msgs = bot.checkMessages() diff --git a/database/asql.py b/database/asql.py new file mode 100644 index 0000000..371df0c --- /dev/null +++ b/database/asql.py @@ -0,0 +1,137 @@ +from mysql.connector import connect +from mysql.connector.cursor_cext import CMySQLCursor +import random + + +class Database: + """Модуль для mysql-connector""" + + def __init__(self, host: str, user: str, password: str): + """Подключение к серверу MySQL""" + self.database = connect(host=host, user=user, password=password) + self.cursor = self.database.cursor() + + def execute(self, query: str, commit=False) -> CMySQLCursor: + """Выполнить SQL запрос + Примечание: в целях безопасности функция игнорирует запросы DROP и TRUNCATE + + Args: + :query: текст запроса + :commit: [optional] сохранить изменения + Returns: + :cursor: объект курсора + """ + if ( + query.lower().find("drop") == -1 + and query.lower().find("truncate") == -1 + ): + self.cursor.execute(query) + if commit: + self.database.commit() + return self.cursor + + def executeFile(self, filename: str, commit=False) -> CMySQLCursor: + """Выполнить запрос из .sql файла + + Args: + :filename: название файла (без расширения) + :commit: [optional] сохранить изменения + Returns: + :cursor: объект курсора + """ + + with open(f'database/{filename}.sql', encoding='utf-8') as f: + query = f.read() + return self.execute(query, commit) + + def initDatabase(self, name: str): + """Создать базу данных, если таковая отсутствует, + и переключиться на неё для использования в дальнейших запросах + Args: + :name: название базы данных + """ + + self.execute(f"CREATE DATABASE IF NOT EXISTS {name}") + self.execute(f"USE {name}") + + def initTable(self, name: str, head: str): + """Создать таблицу, если таковая отсутствует + + TODO: вырезать эту функцию, поскольку теперь БД инициализирутся + из файла + + Args: + :name: название таблицы + :head: двумерный список, в строках которых описаны столбцы таблицы + """ + query = f"CREATE TABLE IF NOT EXISTS `{name}` (" + query += ", ".join([" ".join(i) for i in head]) + query += ");" + self.execute(query) + + def insert(self, name: str, values: dict): + """Вставить значение в таблицу + + Args: + :name: название таблицы + :values: словарь их названий столбцов и их значений + """ + query = f"INSERT IGNORE INTO `{name}` (" + query += ", ".join(values) + ") VALUES (" + query += ( + ", ".join( + [ + f'"{i}"' if (i != None) else "NULL" + for i in values.values() + ] + ) + + ");" + ) + self.execute(query, commit=True) + + def get(self, name: str, condition=None, columns=None) -> list: + """Получить данные из таблицу по запросу вида: + + :SELECT columns FROM name WHERE condition: + + Args: + :name: название таблицы + :condition: SQL условие для выборки, для получения всех строк оставить None + :columns: [optional] список столбцов, которые необходимо выдать, для всех столбцов оставить None + """ + query = "SELECT " + (', '.join(columns) if columns != None else "*") + query += f" FROM `{name}`" + query += f" WHERE {condition};" if condition != None else ";" + result = self.execute(query).fetchall() + return result + + def update(self, name: str, condition: str, new: str): + """Обновить данные в строке + + Args: + :name: название таблицы + :condition: SQL условие для выборки строки + :new: SQL условия для замены значений столбцов + """ + query = f"UPDATE {name}" + query += f" SET {new} WHERE {condition};" + self.execute(query, commit=True) + + def newID(self, name: str, id_name: str) -> str: + """Сгенерировать уникальный ID из 9 цифр + + Args: + :name: название таблицы пользователей + :id_name: название столбца уникальных ID + Returns: + :someID: строка с уникальным ID + """ + someID = random.randint(100000000, 999999999) + + result = self.get(name, f"{id_name} = {someID}") + + exist = result != [] + if not exist: + return str(someID) + else: + self.newID() diff --git a/database/database.sql b/database/database.sql new file mode 100644 index 0000000..9d6a362 --- /dev/null +++ b/database/database.sql @@ -0,0 +1,30 @@ +CREATE DATABASE IF NOT EXISTS `l9_db`; +USE `l9_db`; + +-- Пользователи системы +CREATE TABLE IF NOT EXISTS `users` ( + `l9Id` bigint NOT NULL, + -- Идентификатор пользователя системы + + PRIMARY KEY (`l9Id`) + ); + +-- Сведения о пользователях бота Telegram +CREATE TABLE IF NOT EXISTS `tg_users` ( + `l9Id` bigint NOT NULL, + -- Идентификатор пользователя системы + + `tgId` bigint NOT NULL, + -- ID пользователя в Telegram + + `name` TEXT, + -- (optional) Имя пользователя в Telegram + + `posTag` varchar(30) DEFAULT 'not_started', + -- Позиция пользователя в диалоге с ботом: + -- (default) not_started - не вступил в диалог + -- started - вступил в диалог + + PRIMARY KEY (`l9Id`), + CONSTRAINT `l9_tg` FOREIGN KEY (`l9Id`) REFERENCES `users` (`l9Id`) ON DELETE CASCADE ON UPDATE CASCADE + ); \ No newline at end of file diff --git a/database/l9.py b/database/l9.py new file mode 100644 index 0000000..97ea124 --- /dev/null +++ b/database/l9.py @@ -0,0 +1,26 @@ +from .asql import Database + + +class L9_DB: + """Класс взаимодействия с базой пользователей L9 + (перспектива для сайта) + """ + + def __init__(self, user, password): + self.db = Database('localhost', user, password) + self.db.initDatabase('l9_db') + self.db.executeFile('l9') + + def countUsers(self) -> int: + return len(self.db.get('users', None, ['l9Id'])) + + def initUser(self, uid): + result = self.db.get('users', f"l9Id = {uid}", ["l9Id"]) + if result == []: + l9Id = self.db.newID('users', "l9Id") + user = {"l9Id": l9Id} + self.db.insert('users', user) + else: + l9Id = result[0][0] + + return l9Id diff --git a/database/l9.sql b/database/l9.sql new file mode 100644 index 0000000..6545765 --- /dev/null +++ b/database/l9.sql @@ -0,0 +1,7 @@ +-- Пользователи системы +CREATE TABLE IF NOT EXISTS `users` ( + `l9Id` bigint NOT NULL, + -- Идентификатор пользователя системы + + PRIMARY KEY (`l9Id`) + ); \ No newline at end of file diff --git a/database/tg.py b/database/tg.py new file mode 100644 index 0000000..037241a --- /dev/null +++ b/database/tg.py @@ -0,0 +1,33 @@ +from .l9 import L9_DB +import telegram + + +class TG_DB: + """Класс взаимодействия с БД пользователей бота в Telegram""" + + def __init__(self, l9lk: L9_DB): + self.l9lk = l9lk + self.db = l9lk.db + self.db.executeFile('tg') + + def getTag(self, query: telegram.Message) -> (str, str, str): + """Получить тэг и l9Id пользователя""" + + tgId = query.from_user.id + name = f'{query.from_user.first_name or ""} {query.from_user.last_name or ""}' + + l9Id = self.db.get('tg_users', f"tgId = {tgId}", ["l9Id"]) + if l9Id == []: + l9Id = self.l9lk.initUser(0) + user = {"l9Id": l9Id, "tgId": tgId, "name": name} + self.db.insert('tg_users', user) + else: + l9Id = l9Id[0][0] + + tag = self.db.get('tg_users', f"tgId = {tgId}", ["posTag"])[0][0] + + return tag, l9Id, f'{tgId}\t{tag}\t{name}\t{query.text}' + + def changeTag(self, tgId: int, tag: str) -> None: + """Сменить тэг пользователя""" + self.db.update('tg_users', f"tgId = {tgId}", f"posTag = '{tag}'") diff --git a/database/tg.sql b/database/tg.sql new file mode 100644 index 0000000..38f161c --- /dev/null +++ b/database/tg.sql @@ -0,0 +1,19 @@ +-- Сведения о пользователях бота Telegram +CREATE TABLE IF NOT EXISTS `tg_users` ( + `l9Id` bigint NOT NULL, + -- Идентификатор пользователя системы + + `tgId` bigint NOT NULL, + -- ID пользователя в Telegram + + `name` TEXT, + -- (optional) Имя пользователя в Telegram + + `posTag` varchar(30) DEFAULT 'not_started', + -- Позиция пользователя в диалоге с ботом: + -- (default) not_started - только что в диалог + -- add - добавляет группу + + PRIMARY KEY (`l9Id`), + CONSTRAINT `l9_tg` FOREIGN KEY (`l9Id`) REFERENCES `users` (`l9Id`) ON DELETE CASCADE ON UPDATE CASCADE + ); \ No newline at end of file diff --git a/tg/keyboards.py b/tg/keyboards.py new file mode 100644 index 0000000..17d5dcc --- /dev/null +++ b/tg/keyboards.py @@ -0,0 +1,32 @@ +from telegram import ( + InlineKeyboardMarkup, + InlineKeyboardButton, + ReplyKeyboardMarkup, + KeyboardButton, +) + + +class Keyboard: + def confirm() -> InlineKeyboardMarkup: + """Клавиатура Да/Нет""" + buttons = [ + [ + InlineKeyboardButton("Да", callback_data="yes"), + InlineKeyboardButton("Нет", callback_data="no"), + ] + ] + return InlineKeyboardMarkup(buttons) + + def cancel() -> ReplyKeyboardMarkup: + """Кнопка отмены""" + buttons = [[KeyboardButton("Отмена")]] + return ReplyKeyboardMarkup( + buttons, resize_keyboard=True, one_time_keyboard=True + ) + + def menu() -> ReplyKeyboardMarkup: + """Кнопка Главного меню""" + buttons = [[KeyboardButton("Главное меню")]] + return ReplyKeyboardMarkup( + buttons, resize_keyboard=True, one_time_keyboard=True + ) diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..9b9210f --- /dev/null +++ b/utils/config.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +import json +import os + + +def loadJSON(name): + path = f"{name}.json" + if os.path.exists(path): + with open(path, encoding='utf-8') as file: + return json.load(file) + + +def saveJSON(name, dct): + path = f"{name}.json" + with open(path, "w", encoding='utf-8') as file: + json.dump(dct, file, ensure_ascii=False, indent="\t")