commit 937d38de5cde390d7ff0739f191cd55f7c962d8a Author: AB Date: Fri Sep 6 22:52:28 2019 +0300 Init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e55fba0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.7.4-stretch +WORKDIR /doka2_bot +COPY . /doka2_bot +RUN pip install -r requirements.txt +CMD python3 /doka2_bot/app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..cf99487 --- /dev/null +++ b/app.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This program is dedicated to the public domain under the CC0 license. + +import logging +import os +import sys + +from telegram import * +from telegram.ext import Updater, CommandHandler, CallbackQueryHandler +from database import DataBase + +DB = DataBase('data.sql') + +TOKEN = os.environ.get('TOKEN') +if TOKEN == None: + print('Define TOKEN env var!') + sys.exit(1) + +# Enable logging +logging.basicConfig(format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO) + +logger = logging.getLogger(__name__) + +def dota(update, context): + if len(context.args) == 0: + keyboard = [ + [ + InlineKeyboardButton("Patches", callback_data="patches"), + InlineKeyboardButton("Heroes", callback_data="heroes"), + InlineKeyboardButton("Items", callback_data="items"), + InlineKeyboardButton("Close", callback_data="close"), + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + update.message.reply_text('What do you wanna know?', reply_markup=reply_markup) + +def smart_append(line, text, lenght=4000): + if len(line) + len(text) > 4000: + text = text[len(line):] + text = 'Full message too long\n' + text + return text + line + +def shorten(text): + max_len = 4000 + if len(text) > max_len: + text = text[len(text)-max_len:] + return 'Message too long...\n' + text + +def button(update, context): + query = update.callback_query + if query.data.split('_')[0] == 'close': + query.edit_message_text('Bye') + if query.data.split('_')[0] == 'dota': + keyboard = [ + [ + InlineKeyboardButton("Patches", callback_data="patches"), + InlineKeyboardButton("Heroes", callback_data="heroes"), + InlineKeyboardButton("Items", callback_data="items"), + InlineKeyboardButton("Close", callback_data="close"), + ] + ] + reply_markup = InlineKeyboardMarkup(keyboard) + query.edit_message_text('What do you wanna know?', reply_markup=reply_markup) + if query.data.split('_')[0] == 'patch': + patch = query.data.split('_')[1] + reply_markup = InlineKeyboardMarkup([[InlineKeyboardButton("Back", callback_data='patches'),]]) + text = f'Patch *{patch}*\n' + general_info = DB.get_general_history(patch) + i = 1 + for change in general_info: + text += f" {i} - {change[0]}\n" + i += 1 + query.edit_message_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup) + if query.data.split('_')[0] == 'item': + keyboard = [[InlineKeyboardButton("Back", callback_data='items')]] + if 'expand' in query.data: + expand = True + else: + expand = False + keyboard[0].append(InlineKeyboardButton( + "Expand", + callback_data=f'{query.data}_expand') + ) + reply_markup = InlineKeyboardMarkup(keyboard) + + expand = True if 'expand' in query.data else False + item_name = query.data.split('_')[1] + item_history = DB.get_item_history(item_name) + text = f'*{item_name}* update history\n' + cur_patch = '' + for upd in item_history: + if upd[1] is None and not expand: + continue + if upd[0] != cur_patch: + no_info = '*No changes*' if upd[1] is None else '' + text += f"\n* {upd[0]} {no_info} *\n" + cur_patch = upd[0] + if upd[1] is not None: + text += f'\t\t 🔹 {upd[1]}\n' + query.edit_message_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup) + + if query.data.split('_')[0] == 'hero': + keyboard = [[InlineKeyboardButton("Back", callback_data='heroes')]] + if 'expand' in query.data: + expand = True + else: + expand = False + keyboard[0].append(InlineKeyboardButton( + "Expand", + callback_data=f'{query.data}_expand') + ) + reply_markup = InlineKeyboardMarkup(keyboard) + + expand = True if 'expand' in query.data else False + hero_name = query.data.split('_')[1] + hero_history = DB.get_hero_history(hero_name) + text = f'*{hero_name}* update history\n' + cur_patch = '' + cur_type = '' + for upd in hero_history: + if upd[0] != cur_patch: + if upd[1] is None and not expand: + continue + no_info = '*No changes*' if upd[1] is None else '' + text += f"\n* {upd[0]} {no_info} *\n" + cur_patch = upd[0] + cur_type = '' + if upd[1] is not None: + if upd[1] != cur_type: +# text = smart_append(f"🔆*{upd[1].capitalize()}*\n", text) + text += f"🔆*{upd[1].capitalize()}*\n" + cur_type = upd[1] +# text = smart_append(f'\t\t 🔹 {upd[2]}\n', text) + text += f'\t\t 🔹 {upd[2]}\n' + text = shorten(text) + query.edit_message_text(text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup) + + if query.data.split('_')[0] == 'patches': + patches = DB.get_patch_list() + patches.reverse() + keyboard = [[]] + in_a_row = 5 + for patch in patches: + if len(keyboard[-1]) == in_a_row: + keyboard.append(list()) + keyboard[-1].append(InlineKeyboardButton(f"{patch}", callback_data=f"patch_{patch}")) + keyboard.append([InlineKeyboardButton("Back", callback_data='dota')]) + reply_markup = InlineKeyboardMarkup(keyboard) + query.edit_message_text(text="Select patch", reply_markup=reply_markup) + if query.data.split('_')[0] == 'heroes': + per_page = 20 + try: + page = int(query.data.split('_')[1]) + except: + page = 0 + heroes = DB.get_heroes_list() + heroes.sort() + last_hero = page*per_page+per_page + if len(heroes) <= last_hero - 1: + last_hero = len(heroes) + keyboard = [[]] + in_a_row = 2 + for hero in heroes[page*per_page:last_hero]: + if len(keyboard[-1]) == in_a_row: + keyboard.append(list()) + keyboard[-1].append(InlineKeyboardButton(f"{hero}", callback_data=f"hero_{hero}")) + keyboard.append([]) + if page != 0: + keyboard[-1].append( + InlineKeyboardButton("<=", callback_data=f'heroes_{page-1}'), + ) + if len(heroes) != last_hero: + keyboard[-1].append( + InlineKeyboardButton("=>", callback_data=f'heroes_{page+1}'), + ) + + keyboard.append([InlineKeyboardButton("Back", callback_data='dota')]) + reply_markup = InlineKeyboardMarkup(keyboard) + query.edit_message_text(text=f"Select hero {page*per_page}:{page*per_page+per_page}", reply_markup=reply_markup) + if query.data.split('_')[0] == 'items': + per_page = 20 + try: + page = int(query.data.split('_')[1]) + except: + page = 0 + items = DB.get_items_list() + items.sort() + keyboard = [[]] + last_item = page*per_page+per_page + if len(items) <= last_item - 1: + last_item = len(items) + in_a_row = 2 + for item in items[page*per_page:last_item]: + if len(keyboard[-1]) == in_a_row: + keyboard.append(list()) + keyboard[-1].append(InlineKeyboardButton(f"{item}", callback_data=f"item_{item}")) + keyboard.append([]) + if page != 0: + keyboard[-1].append( + InlineKeyboardButton("<=", callback_data=f'items_{page-1}'), + ) + if len(items) != last_item: + keyboard[-1].append( + InlineKeyboardButton("=>", callback_data=f'items_{page+1}'), + ) + keyboard.append([InlineKeyboardButton("Back", callback_data='dota')]) + reply_markup = InlineKeyboardMarkup(keyboard) + query.edit_message_text(text="Select item", reply_markup=reply_markup) + +def error(update, context): + """Log Errors caused by Updates.""" + logger.warning('Update "%s" caused error "%s"', update, context.error) + +def main(): + """Run bot.""" + updater = Updater(TOKEN, use_context=True) + + dp = updater.dispatcher + + dp.add_handler(CallbackQueryHandler(button)) + dp.add_handler(CommandHandler("dota", dota, + pass_args=True, + pass_job_queue=True, + pass_chat_data=True)) + + # log all errors + dp.add_error_handler(error) + + # Start the Bot + updater.start_polling() + + # Block until you press Ctrl-C or the process receives SIGINT, SIGTERM or + # SIGABRT. This should be used most of the time, since start_polling() is + # non-blocking and will stop the bot gracefully. + updater.idle() + + +if __name__ == '__main__': + main() diff --git a/data.sql b/data.sql new file mode 100644 index 0000000..d820e41 --- /dev/null +++ b/data.sql @@ -0,0 +1,36 @@ +BEGIN TRANSACTION; +CREATE TABLE IF NOT EXISTS "heroes" ( + "name" TEXT NOT NULL, + PRIMARY KEY("name") +); +CREATE TABLE IF NOT EXISTS "patches" ( + "version" TEXT NOT NULL, + "release_date" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY("version") +); +CREATE TABLE IF NOT EXISTS "items" ( + "name" TEXT NOT NULL, + PRIMARY KEY("name") +); +CREATE TABLE IF NOT EXISTS "hero_changes" ( + "type" TEXT NOT NULL, + "patch" INT NOT NULL, + "hero" INT NOT NULL, + "info" TEXT NOT NULL, + "meta" TEXT, + PRIMARY KEY("hero", "patch", "info") +); +CREATE TABLE IF NOT EXISTS "item_changes" ( + "patch" INT NOT NULL, + "item" INT NOT NULL, + "info" TEXT NOT NULL, + "meta" TEXT, + PRIMARY KEY("item", "patch", "info") +); +CREATE TABLE IF NOT EXISTS "general_changes" ( + "patch" INT NOT NULL, + "info" TEXT NOT NULL, + "meta" TEXT, + PRIMARY KEY("patch", "info") +); +COMMIT; diff --git a/data.sqlite b/data.sqlite new file mode 100644 index 0000000..d4b7b19 Binary files /dev/null and b/data.sqlite differ diff --git a/database.py b/database.py new file mode 100644 index 0000000..8c5469a --- /dev/null +++ b/database.py @@ -0,0 +1,256 @@ +# +# Copyright (c) 2019, UltraDesu +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of the nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY UltraDesu ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL UltraDesu BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +""" +.. module:: models + :synopsis: Contains database action primitives. +.. moduleauthor:: AB +""" + +import sqlite3 +import logging + +logging.basicConfig( + level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +log = logging.getLogger(__name__) + + +# class DataBase create or use existent SQLite database file. It provides +# high-level methods for database. +class DataBase: + """This class create or use existent SQLite database file. It provides + high-level methods for database.""" + def __init__(self, scheme, basefile='data.sqlite'): + """ + Constructor creates new SQLite database if + it doesn't exist. Uses SQL code from file for DB init. + :param scheme: sql filename + :type scheme: string + :return: None + """ + self.scheme = '' + self.basefile = basefile + try: + conn = self.connect(basefile=basefile) + except: + log.debug('Could not connect to DataBase.') + return None + with open(scheme, 'r') as scheme_sql: + sql = scheme_sql.read() + self.scheme = sql + if conn is not None: + try: + cursor = conn.cursor() + cursor.executescript(sql) + except Exception as e: + log.debug(f'Could not create scheme - {e}') + else: + log.debug("Error! cannot create the database connection.") + log.info('DB created.') + self.close(conn) + + def connect(self, basefile): + """ + Create connect object for basefile + :param basefile: SQLite database filename + :type basefile: string + :return: sqlite3 connect object + """ + log.debug("Open connection to %s" % basefile) + return sqlite3.connect(basefile, check_same_thread=False) + + def execute(self, sql): + """ + Execute SQL code. First of all connect to self.basefile. Close + connection after execution. + :param sql: SQL code + :type sql: string + :return: list of response. Empty list when no rows are available. + """ + conn = self.connect(basefile=self.basefile) + log.debug("Executing: %s" % sql) + cursor = conn.cursor() + cursor.execute(sql) + conn.commit() + result = cursor.fetchall() + self.close(conn) + return result + + def add_patch(self, patch): + sql = f"INSERT OR IGNORE INTO patches('version') VALUES ('{patch}')" + self.execute(sql) + return True + + def add_hero(self, hero): + hero = hero.replace("'", "''") + sql = f"INSERT OR IGNORE INTO heroes('name') VALUES ('{hero}')" + self.execute(sql) + return True + + def add_item(self, item): + item = item.replace("'", "''") + sql = f"INSERT OR IGNORE INTO items('name') VALUES ('{item}')" + self.execute(sql) + return True + + def add_general_changes(self, + patch, + info): + info = info.replace("'", "''") + patch_id = self.get_patch_id(patch) + sql = f"""INSERT OR IGNORE INTO + general_changes('patch', 'info') + VALUES ( + '{patch_id}', + '{info}')""" + self.execute(sql) + return True + + def add_item_changes(self, + patch, + item, + info): + item = item.replace("'", "''") + info = info.replace("'", "''") + item_id = self.get_item_id(item) + patch_id = self.get_patch_id(patch) + sql = f"""INSERT OR IGNORE INTO + item_changes('patch', 'item', 'info') + VALUES ( + '{patch_id}', + '{item_id}', + '{info}')""" + self.execute(sql) + return True + + def add_hero_changes(self, + change_type, + patch, + hero, + info, + meta=None): + hero = hero.replace("'", "''") + info = info.replace("'", "''") + if meta: + meta = meta.replace("'", "''") + hero_id = self.get_hero_id(hero) + patch_id = self.get_patch_id(patch) + sql = f"""INSERT OR IGNORE INTO + hero_changes('type', 'patch', 'hero', 'info', 'meta') + VALUES ( + '{change_type}', + '{patch_id}', + '{hero_id}', + '{info}', + '{meta}')""" + self.execute(sql) + return True + + def get_patch_id(self, patch): + sql = f"SELECT rowid FROM patches WHERE version = '{patch}'" + ret = self.execute(sql) + return ret[0][0] + + def get_item_id(self, item): + sql = f"SELECT rowid FROM items WHERE name = '{item}'" + ret = self.execute(sql) + return ret[0][0] + + def get_hero_id(self, hero): + sql = f"SELECT rowid FROM heroes WHERE name = '{hero}'" + ret = self.execute(sql) + return ret[0][0] + + def get_items_list(self): + sql = f"SELECT name FROM items" + ret = self.execute(sql) + items = list() + for item in ret: + items.append(item[0]) + return items + + def get_heroes_list(self): + sql = f"SELECT name FROM heroes" + ret = self.execute(sql) + heroes = list() + for hero in ret: + heroes.append(hero[0]) + return heroes + + def get_patch_list(self): + sql = f"SELECT version FROM patches" + ret = self.execute(sql) + patches = list() + for patch in ret: + patches.append(patch[0]) + return patches + + def get_general_history(self, patch): + patch = patch.replace("'", "''") + patch = self.get_patch_id(patch) + sql = f"""SELECT gc.info FROM general_changes gc + LEFT JOIN patches p on p.ROWID = gc.patch + WHERE gc.patch = '{patch}'""" + return self.execute(sql) + + def get_hero_history(self, hero): + hero = hero.replace("'", "''") + hero = self.get_hero_id(hero) + sql = f"""SELECT p.version, a.type, a.info, a.meta FROM + patches p + LEFT JOIN ( + SELECT p.version, hc.type, hc.info, hc.meta FROM `heroes` h + LEFT JOIN hero_changes hc ON hc.hero = h.ROWID + LEFT JOIN patches p ON hc.patch = p.ROWID + WHERE hc.hero = {hero} + ) a ON p.version = a.version + ORDER BY p.rowid DESC;""" + return self.execute(sql) + + def get_item_history(self, item): + item = item.replace("'", "''") + item = self.get_item_id(item) + sql = f"""SELECT p.version, a.info FROM + patches p + LEFT JOIN ( + SELECT p.version, ic.info FROM item_changes ic + LEFT JOIN patches p ON p.rowid = ic.patch + WHERE ic.item = {item} + ) a ON p.version = a.version + ORDER BY p.rowid DESC""" + return self.execute(sql) + + def close(self, conn): + """ + Close connection object instance. + :param conn: sqlite3 connection object + :type conn: object + :return: None + """ + log.debug("Close connection to %s" % self.basefile) + conn.close() + diff --git a/dota_news.py b/dota_news.py new file mode 100644 index 0000000..7eaa5e5 --- /dev/null +++ b/dota_news.py @@ -0,0 +1,91 @@ +import urllib.request +import bs4 +from database import DataBase + +URL = "https://www.dota2.com/patches/" + +DB = DataBase('data.sql') + +raw_content = urllib.request.urlopen(URL) +content = raw_content.read().decode('utf8') + +raw_content.close() + +schema = bs4.BeautifulSoup(content, 'html.parser') + +raw_patches = schema.find(id="PatchSelector") +patches = list() +for patch in raw_patches: + if isinstance(patch, bs4.element.NavigableString): + continue + if patch.text[0].isdigit(): + patches.append(patch.text) + DB.add_patch(patch.text) +print(f"Found patches: {patches}") + +#patches = ['7.22'] + +for patch in patches: + raw_content = urllib.request.urlopen(URL + patch) + content = raw_content.read().decode('utf8') + raw_content.close() + schema = bs4.BeautifulSoup(content, 'html.parser') + + # hero updates + for hero in schema.findAll('div', {"class": 'HeroNotes'}): + hero_name = hero.select('.HeroName')[0].get_text() + DB.add_hero(hero_name) + patch_notes = list() + common_notes = hero.find('ul', {"class": 'HeroNotesList'}) + if common_notes is not None: + for note in common_notes.findChildren("li", recursive=False): + patch_notes.append(note.text) + DB.add_hero_changes( + change_type='common', + patch=patch, + hero=hero_name, + info=note.text) + ability_notes = dict() + for ability in hero.findAll('div', {"class": 'HeroAbilityNotes'}): + ability_name = ability.select('.AbilityName')[0].text + ability_notes[ability_name] = list() + for note in ability.findAll('li', {"class": 'PatchNote'}): + ability_notes[ability_name].append(note.text) + DB.add_hero_changes( + change_type='ability', + patch=patch, + hero=hero_name, + info=note.text, + meta=ability_name) + talent_notes = list() + talents = hero.find('div', {"class": 'TalentNotes'}) + if talents is not None: + for talent in talents.findAll('li'): + talent_notes.append(talent.text) + DB.add_hero_changes( + change_type='talent', + patch=patch, + hero=hero_name, + info=talent.text) + + # item updates + item_notes = dict() + for item in schema.findAll('div', {"class": 'ItemNotes'}): + item_name = item.select('.ItemName')[0].get_text() + DB.add_item(item_name) + item_notes[item_name] = list() + for note in item.findAll('li', {"class": 'PatchNote'}): + item_notes[item_name].append(note.text) + DB.add_item_changes( + patch=patch, + item=item_name, + info=note.text) + + # general updates + general = schema.find(id='GeneralSection') + print(general) + if general is not None: + for note in general.findAll('li', {"class": 'PatchNote'}): + DB.add_general_changes( + patch=patch, + info=note.text) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1f1f9ac --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +beautifulsoup4==4.8.0 +python-telegram-bot==12.0.0b1