diff --git a/app/bot.py b/app/bot.py index be7de87..7117cc9 100644 --- a/app/bot.py +++ b/app/bot.py @@ -1,5 +1,7 @@ from aiotg import Bot, Chat, CallbackQuery, BotApiError from app.utils import init_logging +from app.telegram_user import TelegramUser +from app.game import Game from app.game_registry import GameRegistry from app.game_session import GameSession import asyncio @@ -56,20 +58,58 @@ async def on_help_command(chat: Chat, match): ) +@bot.command("(?s)/game\s+(.+)$") +@bot.command("/(game)$") +async def on_game_command(chat: Chat, match): + chat_id = chat.id + facilitator_message_id = str(chat.message["message_id"]) + game_name = match.group(1) + facilitator = TelegramUser.from_dict(chat.sender) + + if game_name == "game": + game_name = "(no name)" + + active_game = await game_registry.find_active_game(chat_id, facilitator) + if active_game is not None: + await chat.send_text( + text="You have active game already. Need to /game_end to start another one." + ) + return + + game = Game(chat_id, facilitator_message_id, game_name, facilitator) + await create_game(chat, game) + + +@bot.command("/game_end$") +async def on_game_end_command(chat: Chat, match): + chat_id = chat.id + facilitator = TelegramUser.from_dict(chat.sender) + + active_game = await game_registry.find_active_game(chat_id, facilitator) + + if active_game is None: + await chat.send_text( + text="You do not have active game. Need to run /game to start game." + ) + return + + await end_game(chat, active_game) + + @bot.command("(?s)/poker\s+(.+)$") @bot.command("/(poker)$") async def on_poker_command(chat: Chat, match): chat_id = chat.id facilitator_message_id = str(chat.message["message_id"]) topic = match.group(1) - facilitator = chat.sender + facilitator = TelegramUser.from_dict(chat.sender) if topic == "poker": topic = "(no topic)" - game_id = 0 + game = await game_registry.find_active_game(chat_id, facilitator) - game_session = GameSession(game_id, chat_id, facilitator_message_id, topic, facilitator) + game_session = GameSession(game, chat_id, facilitator_message_id, topic, facilitator) await create_game_session(chat, game_session) @@ -128,7 +168,7 @@ async def on_facilitator_operation_click(chat: Chat, callback_query: CallbackQue if not game_session: return await callback_query.answer(text="No such game session") - if callback_query.src["from"]["id"] != game_session.facilitator["id"]: + if callback_query.src["from"]["id"] != game_session.facilitator.id: return await callback_query.answer(text="Operation `{}` is available only for facilitator".format(operation)) if operation in GameSession.OPERATION_START_ESTIMATION: @@ -165,7 +205,7 @@ async def run_operation_end_estimation(chat: Chat, game_session: GameSession): async def run_re_estimate(chat: Chat, game_session: GameSession): message = { - "text": game_session.render_message_text(), + "text": game_session.render_system_message_text(), } game_session.re_estimate() @@ -179,15 +219,27 @@ async def run_re_estimate(chat: Chat, game_session: GameSession): await create_game_session(chat, game_session) -async def create_game_session(chat: Chat, game_session: GameSession): - response = await chat.send_text(**game_session.render_message()) - game_session.system_message_id = response["result"]["message_id"] - await game_registry.create_game_session(game_session) +async def create_game(chat: Chat, game_prototype: Game): + response = await chat.send_text(**game_prototype.render_system_message()) + game_prototype.system_message_id = response["result"]["message_id"] + await game_registry.create_game(game_prototype) + + +async def end_game(chat: Chat, game: Game): + await game_registry.end_game(game) + game_statistics = await game_registry.get_game_statistics(game) + await chat.send_text(**game.render_results_system_message(game_statistics)) + + +async def create_game_session(chat: Chat, game_session_prototype: GameSession): + response = await chat.send_text(**game_session_prototype.render_system_message()) + game_session_prototype.system_message_id = response["result"]["message_id"] + await game_registry.create_game_session(game_session_prototype) async def edit_message(chat: Chat, game_session: GameSession): try: - await bot.edit_message_text(chat.id, game_session.system_message_id, **game_session.render_message()) + await bot.edit_message_text(chat.id, game_session.system_message_id, **game_session.render_system_message()) except BotApiError: logbook.exception("Error when updating markup") diff --git a/app/game.py b/app/game.py new file mode 100644 index 0000000..b4e196d --- /dev/null +++ b/app/game.py @@ -0,0 +1,81 @@ +from app.telegram_user import TelegramUser + + +class Game: + STATUS_STARTED = "started" + STATUS_ENDED = "ended" + + def __init__(self, chat_id: int, facilitator_message_id: int, name: str, facilitator: TelegramUser): + self.id = None + self.system_message_id = None + self.chat_id = chat_id + self.facilitator_message_id = facilitator_message_id + self.status = self.STATUS_STARTED + self.name = name + self.facilitator = facilitator + + def render_system_message(self): + return { + "text": self.render_system_message_text(), + } + + def render_results_system_message(self, game_statistics): + return { + "text": self.render_results_system_message_text(game_statistics), + } + + def render_system_message_text(self) -> str: + result = "" + + result += self.render_name_text() + result += "\n" + result += self.render_facilitator_text() + + return result + + def render_results_system_message_text(self, game_statistics) -> str: + result = "" + + result += self.render_name_text() + result += "\n" + result += self.render_facilitator_text() + result += "\n" + result += "\n" + result += self.render_statistics_text(game_statistics) + + return result + + def render_facilitator_text(self) -> str: + return "Facilitator: {}".format(self.facilitator.to_string()) + + def render_name_text(self) -> str: + if self.status == self.STATUS_STARTED: + return "Planning poker started: " + self.name + elif self.status == self.STATUS_ENDED: + return "Planning poker ended: " + self.name + else: + return "" + + def render_statistics_text(self, game_statistics) -> str: + result = "" + + result += "Estimated topics count: {}".format(game_statistics["estimated_game_sessions_count"]) + result += "\n" + result += "Estimations count: {}".format(game_statistics["game_sessions_count"]) + + return result + + + def to_dict(self): + return { + "facilitator": self.facilitator.to_dict(), + } + + @classmethod + def from_dict(cls, chat_id: int, facilitator_message_id: int, name: str, facilitator: TelegramUser): + return cls( + chat_id, + facilitator_message_id, + name, + facilitator, + ) diff --git a/app/game_registry.py b/app/game_registry.py index debf3e3..46e118a 100644 --- a/app/game_registry.py +++ b/app/game_registry.py @@ -1,4 +1,6 @@ +from app.game import Game from app.game_session import GameSession +from app.telegram_user import TelegramUser import aiosqlite import json @@ -7,19 +9,38 @@ class GameRegistry: def __init__(self): self.db_connection = None - async def init_db(self, db_path): + async def init_db(self, db_path: str): db_connection = aiosqlite.connect(db_path) db_connection.daemon = True self.db_connection = await db_connection + self.db_connection.row_factory = aiosqlite.Row await self.run_migrations() async def run_migrations(self): + await self.db_connection.execute( + """ + CREATE TABLE IF NOT EXISTS game ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + chat_id INTEGER NOT NULL, + facilitator_id INTEGER NOT NULL, + facilitator_message_id INTEGER NOT NULL, + system_message_id INTEGER NOT NULL, + status TEXT NOT NULL, + name TEXT NOT NULL, + json_data TEXT NOT NULL, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ) + """ + ) + await self.db_connection.execute( """ CREATE TABLE IF NOT EXISTS game_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, - game_id INTEGER NOT NULL, + game_id INTEGER, chat_id INTEGER NOT NULL, + facilitator_id INTEGER NOT NULL, facilitator_message_id INTEGER NOT NULL, system_message_id INTEGER NOT NULL, phase TEXT NOT NULL, @@ -37,22 +58,165 @@ async def run_migrations(self): """ ) - async def find_active_game_session(self, chat_id: int, facilitator_message_id: int) -> GameSession: + async def create_game(self, game: Game): + await self.db_connection.execute( + """ + INSERT INTO game + ( + chat_id, + facilitator_id, + facilitator_message_id, + system_message_id, + status, + name, + json_data, + created_at, + updated_at + ) VALUES ( + :chat_id, + :facilitator_id, + :facilitator_message_id, + :system_message_id, + :status, + :name, + :json_data, + datetime('now'), + datetime('now') + ) + """, + { + "chat_id": game.chat_id, + "facilitator_id": game.facilitator.id, + "facilitator_message_id": game.facilitator_message_id, + "system_message_id": game.system_message_id, + "status": game.status, + "name": game.name, + "json_data": json.dumps(game.to_dict()), + } + ) + await self.db_connection.commit() + + async def end_game(self, game: Game): + await self.db_connection.execute( + """ + UPDATE game + SET status = :game_status + WHERE id = :game_id + """, + { + "game_id": game.id, + "game_status": game.STATUS_ENDED, + } + ) + await self.db_connection.commit() + + game.status = Game.STATUS_ENDED + + async def find_active_game(self, chat_id: int, facilitator: TelegramUser) -> Game: query = """ - SELECT json_data - FROM game_session - WHERE chat_id = ? - AND facilitator_message_id = ? + SELECT + id AS game_id, + facilitator_message_id AS game_facilitator_message_id, + system_message_id AS game_system_message_id, + status AS game_status, + name AS game_name, + json_data AS game_json_data + FROM game + WHERE chat_id = :chat_id + AND facilitator_id = :game_facilitator_id + AND status = :active_game_status ORDER BY system_message_id DESC LIMIT 1 """ - async with self.db_connection.execute(query, (chat_id, facilitator_message_id)) as cursor: - result = await cursor.fetchone() + parameters = { + "chat_id": chat_id, + "game_facilitator_id": facilitator.id, + "active_game_status": Game.STATUS_STARTED, + } + async with self.db_connection.execute(query, parameters) as cursor: + row = await cursor.fetchone() + + if not row: + return None + + game_json_data = json.loads(row["game_json_data"]) + game_facilitator = TelegramUser.from_dict(game_json_data["facilitator"]) + + game = Game.from_dict( + chat_id, + row["game_facilitator_message_id"], + row["game_name"], + game_facilitator, + ) + game.id = row["game_id"] + game.system_message_id = row["game_system_message_id"] + game.status = row["game_status"] + + return game + + async def find_active_game_session(self, chat_id: int, game_session_facilitator_message_id: int) -> GameSession: + query = """ + SELECT + g.id AS game_id, + g.facilitator_message_id AS game_facilitator_message_id, + g.system_message_id AS game_system_message_id, + g.status AS game_status, + g.name AS game_name, + g.json_data AS game_json_data, + gs.facilitator_message_id AS game_session_facilitator_message_id, + gs.system_message_id AS game_session_system_message_id, + gs.phase AS game_session_phase, + gs.topic AS game_session_topic, + gs.json_data AS game_session_json_data + FROM game_session AS gs + LEFT JOIN game AS g + ON gs.game_id = g.id + WHERE gs.chat_id = :chat_id + AND gs.facilitator_message_id = :game_session_facilitator_message_id + ORDER BY gs.system_message_id DESC + LIMIT 1 + """ + parameters = { + "chat_id": chat_id, + "game_session_facilitator_message_id": game_session_facilitator_message_id, + } + async with self.db_connection.execute(query, parameters) as cursor: + row = await cursor.fetchone() - if not result: + if not row: return None - return GameSession.from_dict(0, chat_id, facilitator_message_id, json.loads(result[0])) + if row["game_id"] is None: + game = None + else: + game_json_data = json.loads(row["game_json_data"]) + game_facilitator = TelegramUser.from_dict(game_json_data["facilitator"]) + + game = Game.from_dict( + chat_id, + row["game_facilitator_message_id"], + row["game_name"], + game_facilitator, + ) + game.id = row["game_id"] + game.system_message_id = row["game_system_message_id"] + game.status = row["game_status"] + + game_session_json_data = json.loads(row["game_session_json_data"]) + game_session_facilitator = TelegramUser.from_dict(game_session_json_data["facilitator"]) + + game_session = GameSession.from_dict( + game, + chat_id, + row["game_session_facilitator_message_id"], + row["game_session_topic"], + game_session_facilitator, + game_session_json_data, + ) + game_session.system_message_id = row["game_session_system_message_id"] + game_session.phase = row["game_session_phase"] + + return game_session async def create_game_session(self, game_session: GameSession): await self.db_connection.execute( @@ -61,6 +225,7 @@ async def create_game_session(self, game_session: GameSession): ( game_id, chat_id, + facilitator_id, facilitator_message_id, system_message_id, phase, @@ -69,26 +234,28 @@ async def create_game_session(self, game_session: GameSession): created_at, updated_at ) VALUES ( - ?, - ?, - ?, - ?, - ?, - ?, - ?, + :game_id, + :chat_id, + :facilitator_id, + :facilitator_message_id, + :system_message_id, + :phase, + :topic, + :json_data, datetime('now'), datetime('now') ) """, - ( - game_session.game_id, - game_session.chat_id, - game_session.facilitator_message_id, - game_session.system_message_id, - game_session.phase, - game_session.topic, - json.dumps(game_session.to_dict()), - ) + { + "game_id": game_session.game_id, + "chat_id": game_session.chat_id, + "facilitator_id": game_session.facilitator.id, + "facilitator_message_id": game_session.facilitator_message_id, + "system_message_id": game_session.system_message_id, + "phase": game_session.phase, + "topic": game_session.topic, + "json_data": json.dumps(game_session.to_dict()), + } ) await self.db_connection.commit() @@ -96,17 +263,41 @@ async def update_game_session(self, game_session: GameSession): await self.db_connection.execute( """ UPDATE game_session - SET phase = ?, - json_data = ?, + SET phase = :phase, + json_data = :json_data, updated_at = datetime('now') - WHERE chat_id = ? - AND system_message_id = ? + WHERE chat_id = :chat_id + AND system_message_id = :system_message_id """, - ( - game_session.phase, - json.dumps(game_session.to_dict()), - game_session.chat_id, - game_session.system_message_id, - ) + { + "phase": game_session.phase, + "json_data": json.dumps(game_session.to_dict()), + "chat_id": game_session.chat_id, + "system_message_id": game_session.system_message_id, + } ) await self.db_connection.commit() + + async def get_game_statistics(self, game: Game): + query = """ + SELECT + COUNT(*) AS game_sessions_count, + COUNT(DISTINCT facilitator_message_id) AS estimated_game_sessions_count + FROM game_session + WHERE game_id = :game_id + AND phase = :game_session_phase + """ + parameters = { + "game_id": game.id, + "game_session_phase": GameSession.PHASE_RESOLUTION, + } + async with self.db_connection.execute(query, parameters) as cursor: + row = await cursor.fetchone() + + if not row: + return None + + return { + "game_sessions_count": row["game_sessions_count"], + "estimated_game_sessions_count": row["estimated_game_sessions_count"], + } \ No newline at end of file diff --git a/app/game_session.py b/app/game_session.py index 848406c..2f437cf 100644 --- a/app/game_session.py +++ b/app/game_session.py @@ -1,5 +1,7 @@ from app.discussion_vote import DiscussionVote from app.estimation_vote import EstimationVote +from app.telegram_user import TelegramUser +from app.game import Game import collections import json @@ -21,17 +23,25 @@ class GameSession: ["✂️", "♾️", "❓", "☕"], ] - def __init__(self, game_id, chat_id, facilitator_message_id, topic, facilitator): - self.game_id = game_id + def __init__(self, game: Game, chat_id: int, facilitator_message_id: int, topic: str, facilitator: TelegramUser): + self.id = None + self.system_message_id = None + self.game = game self.chat_id = chat_id self.facilitator_message_id = facilitator_message_id - self.system_message_id = 0 self.phase = self.PHASE_DISCUSSION self.topic = topic self.facilitator = facilitator self.estimation_votes = collections.defaultdict(EstimationVote) self.discussion_votes = collections.defaultdict(DiscussionVote) + @property + def game_id(self) -> int: + if self.game is None: + return None + else: + return self.game.id + def start_estimation(self): self.phase = self.PHASE_ESTIMATION @@ -46,21 +56,23 @@ def re_estimate(self): self.estimation_votes.clear() self.phase = self.PHASE_ESTIMATION - def add_discussion_vote(self, facilitator, vote): - self.discussion_votes[self.facilitator_to_string(facilitator)].set(vote) + def add_discussion_vote(self, player, vote): + self.discussion_votes[self.player_to_string(player)].set(vote) - def add_estimation_vote(self, facilitator, vote): - self.estimation_votes[self.facilitator_to_string(facilitator)].set(vote) + def add_estimation_vote(self, player, vote): + self.estimation_votes[self.player_to_string(player)].set(vote) - def render_message(self): + def render_system_message(self): return { - "text": self.render_message_text(), - "reply_markup": json.dumps(self.render_message_buttons()), + "text": self.render_system_message_text(), + "reply_markup": json.dumps(self.render_system_message_buttons()), } - def render_message_text(self): + def render_system_message_text(self): result = "" + result += self.render_game_text() + result += "\n" result += self.render_facilitator_text() result += "\n" result += self.render_topic_text() @@ -70,8 +82,14 @@ def render_message_text(self): return result + def render_game_text(self): + if self.game is None: + return "" + else: + return "Game: {}".format(self.game.name) + def render_facilitator_text(self): - return "Facilitator: {}".format(self.facilitator_to_string(self.facilitator)) + return "Facilitator: {}".format(self.facilitator.to_string()) def render_topic_text(self): result = "" @@ -131,7 +149,7 @@ def render_estimation_votes_text(self): return result - def render_message_buttons(self): + def render_system_message_buttons(self): layout_rows = [] if self.phase in self.PHASE_DISCUSSION: @@ -171,21 +189,21 @@ def render_message_buttons(self): "inline_keyboard": layout_rows, } - def render_discussion_vote_button(self, vote, text): + def render_discussion_vote_button(self, vote: str, text: str): return { "type": "InlineKeyboardButton", "text": text, "callback_data": "discussion-vote-click-{}-{}".format(self.facilitator_message_id, vote), } - def render_estimation_vote_button(self, vote): + def render_estimation_vote_button(self, vote: str): return { "type": "InlineKeyboardButton", "text": vote, "callback_data": "estimation-vote-click-{}-{}".format(self.facilitator_message_id, vote), } - def render_operation_button(self, operation, text): + def render_operation_button(self, operation: str, text: str): return { "type": "InlineKeyboardButton", "text": text, @@ -193,10 +211,10 @@ def render_operation_button(self, operation, text): } @staticmethod - def facilitator_to_string(facilitator: dict) -> str: + def player_to_string(player: dict) -> str: return "@{} ({})".format( - facilitator.get("username") or facilitator.get("id"), - facilitator["first_name"] + player.get("username") or player.get("id"), + "{} {}".format(player.get("first_name"), player.get("last_name") or "").strip() ) @staticmethod @@ -207,25 +225,20 @@ def votes_to_json(votes): def to_dict(self): return { - "system_message_id": self.system_message_id, # TODO: Move out from json - "phase": self.phase, # TODO: Move out from json - "topic": self.topic, # TODO: Move out from json - "facilitator": self.facilitator, + "facilitator": self.facilitator.to_dict(), "discussion_votes": self.votes_to_json(self.discussion_votes), "estimation_votes": self.votes_to_json(self.estimation_votes), } @classmethod - def from_dict(cls, game_id, chat_id, facilitator_message_id, dict): + def from_dict(cls, game: Game, chat_id: int, facilitator_message_id: int, topic: str, facilitator: TelegramUser, dict): result = cls( - game_id, + game, chat_id, facilitator_message_id, - dict["topic"], - dict["facilitator"], + topic, + facilitator, ) - result.system_message_id = dict["system_message_id"] - result.phase = dict["phase"] for user_id, discussion_vote in dict["discussion_votes"].items(): result.discussion_votes[user_id] = DiscussionVote.from_dict(discussion_vote) diff --git a/app/telegram_user.py b/app/telegram_user.py new file mode 100644 index 0000000..b27188f --- /dev/null +++ b/app/telegram_user.py @@ -0,0 +1,32 @@ +class TelegramUser: + def __init__(self, id: int, is_bot: bool, first_name: str, last_name: str, username: str): + self.id = id + self.is_bot = is_bot + self.first_name = first_name + self.last_name = last_name + self.username = username + + def to_string(self) -> str: + return "@{} ({})".format( + self.username or self.id, + "{} {}".format(self.first_name, self.last_name or "").strip() + ) + + def to_dict(self): + return { + "id": self.id, + "is_bot": self.is_bot, + "first_name": self.first_name, + "last_name": self.last_name, + "username": self.username, + } + + @classmethod + def from_dict(cls, dict): + return cls( + dict.get("id"), + dict.get("is_bot"), + dict.get("first_name"), + dict.get("last_name"), + dict.get("username"), + ) diff --git a/requirements.txt b/requirements.txt index 433fd7b..c199521 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ aiohttp==3.7.4 aiosocksy==0.1.2 -aiosqlite==0.10.0 +aiosqlite==0.17.0 aiotg==1.0.0 argh==0.26.2 async-timeout==3.0.1