Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bug fixes #18

Merged
merged 7 commits into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ This Telegram bot is help you to gather statistics about your games.


## How to use
1. Add the [Secret Hitler Statistics Bot](t.me/SHStatBot) to your Telegram group chat
1. Add the [Secret Hitler Statistics Bot](https://t.me/sh_statistic_collector_bot) to your Telegram group chat
2. Start a new game with the `/game` command to record results of the game
3. Ask players to vote
4. Use the `/save` command to save the game results by replaying the game in the chat. Be aware that only the game creator can save the game.
Expand Down
7 changes: 6 additions & 1 deletion src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
Telegram bot entry point.

"""

import logging
import traceback

Expand All @@ -15,11 +16,15 @@
from src.get_handlers import get_handlers


async def post_init(application: Application, config: AppConfig = AppConfig()):
await application.bot.set_my_commands(commands=config.commands)


def main(config: AppConfig = AppConfig()) -> None:
if not config.telegram_bot_token:
raise ValueError("telegram_bot_token env variable" "wasn't purposed.")

application = Application.builder().token(config.telegram_bot_token).build()
application = Application.builder().token(config.telegram_bot_token).post_init(post_init).build()
application.add_handlers(get_handlers())
application.add_error_handler(handlers.error_handler)
# send a message to the developer when the bot is ready
Expand Down
Empty file added src/callbacks/__init__..py
Empty file.
15 changes: 14 additions & 1 deletion src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from typing import Tuple
from pathlib import Path

from telegram import BotCommand


class AppConfig(BaseSettings):
# Define each configuration item with type annotations
Expand All @@ -25,7 +27,7 @@ class AppConfig(BaseSettings):
# Game poll outcomes
game_poll_outcomes: Tuple[str, ...] = (
"👀 SPECTATOR | NOT A PLAYER 👀",
"I'm Canceler Hitler",
"I'm Chancellor Hitler",
"I'm Dead Hitler",
"I'm Hitler Loser",
"I'm Hitler Winner",
Expand All @@ -42,6 +44,17 @@ class AppConfig(BaseSettings):
fascist_color_stroke: str = "#7A1E16"
stroke_size: str = "12" # TODO: pydantic int setter, str getter

commands: list[BotCommand] = Field(
[
BotCommand("start", "Start using bot"),
BotCommand("help", "Display help"),
BotCommand("game", "Start the game in group chat"),
BotCommand(
"save", "Save the game by replying to poll created by /game command"
),
]
)

class Config:
# Optional: control the source of environment variables
env_file = ".env"
Expand Down
6 changes: 3 additions & 3 deletions src/data_models/Game.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ class Game(BaseModel):
chat_id: int
results: tuple[
PollResult, ...
] # Literal["Hitler Canceler", "Fascist Law", "Hitler Death", "Liberal Law"]
] # Literal["Hitler Chancellor", "Fascist Law", "Hitler Death", "Liberal Law"]
creator_id: int

@field_validator("results", mode="after")
Expand All @@ -20,7 +20,7 @@ def validate_results(
cls, results: tuple[PollResult]
) -> Literal["CH", "DH", "FW", "LW"]:
outcomes = set(outcome.get_answer_as_text() for outcome in results)
if "I'm Canceler Hitler" in outcomes:
if "I'm Chancellor Hitler" in outcomes:
return "CH"
if "I'm Dead Hitler" in outcomes:
return "DH"
Expand All @@ -37,5 +37,5 @@ def validate_results(
):
return "FW"
raise ValueError(
f"Invalid results '{v}' for Game. Results must be one of {config.GAME_POLL_OUTCOMES}"
f"Invalid results '{results}' for Game. Results must be one of {config.GAME_POLL_OUTCOMES}"
)
12 changes: 9 additions & 3 deletions src/data_models/Player.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from typing import Optional
from pydantic import BaseModel, field_validator
from pydantic import BaseModel, field_validator, Field
import re


class Player(BaseModel):
telegram_user_id: int
username: str
username: Optional[str | None] = Field(None)
first_name: Optional[str | None]
full_name: Optional[str | None]
last_name: Optional[str | None]
Expand All @@ -14,4 +15,9 @@ class Player(BaseModel):
@field_validator("is_bot", mode="after")
@classmethod
def validate_bot(cls, v: bool) -> str:
return "TRUE" if v else "FALSE" # sqlite3 does not support a boolean type
return "TRUE" if v else "FALSE" # sqlite3 does not support a boolean type # todo maybe use 1 and 0

@field_validator("username", mode="after")
@classmethod
def validate_username(cls, v: str) -> str:
return re.sub(r"[\W_]+", "", v)
6 changes: 3 additions & 3 deletions src/data_models/Poll.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Literal
from typing import Literal, Optional

from pydantic import BaseModel
from pydantic import BaseModel, Field


class Poll(BaseModel):
Expand All @@ -9,4 +9,4 @@ class Poll(BaseModel):
chat_id: int
chat_name: str
creator_id: int
creator_username: str
creator_username: Optional[str | None] = Field(None)
2 changes: 1 addition & 1 deletion src/data_models/Record.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def shorten_role(
cls, v: str
) -> Optional[Literal["CH", "DH", "HW", "HL", "LW", "LL", "FW", "FL"] | None]:
match v:
case "I'm Canceler Hitler":
case "I'm Chancellor Hitler":
return "CH"
case "I'm Dead Hitler":
return "DH"
Expand Down
10 changes: 10 additions & 0 deletions src/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class GroupChatRequiredException(Exception):
"""Exception raised when an attempt is made to start a game outside a group chat."""

def __init__(self, user_id, message="Game can only be started in a group chat."):
self.user_id = user_id
self.message = message
super().__init__(self.message)

def __str__(self):
return f"{self.message} User ID: {self.user_id}"
38 changes: 29 additions & 9 deletions src/handlers/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,41 @@

from src.data_models.Poll import Poll
from src.data_models.Playroom import Playroom
from src.exceptions import GroupChatRequiredException
from src.services.db_service import save_playroom, save_poll
from src.config import AppConfig
from src.utils import is_message_from_group_chat, try_to_delete_message


async def game(
update: Update, context: ContextTypes.DEFAULT_TYPE, config: AppConfig = AppConfig()
) -> None:
"""Sends a predefined poll and saves its metadata to the database."""

if not is_message_from_group_chat(update.effective_message):
await update.effective_message.reply_text(
"You can only start a game in a group chat.\nPlease add me to a group chat and try again."
)
raise GroupChatRequiredException(user_id=update.effective_user.id)

questions = config.game_poll_outcomes
message = None
try:
message = await context.bot.send_poll(
update.effective_chat.id,
f"@{update.effective_user.username} wants you to record the game. Please choose your outcome:",
questions,
is_anonymous=False,
allows_multiple_answers=False,
disable_notification=True,
)
except Exception as e:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider logging the exception caught when sending a poll fails. This can aid in debugging and understanding the specific issues encountered during operation.

+ import logging
  ...
  except Exception as e:
+     logging.error(f"Failed to send poll: {e}")
      await update.effective_message.reply_text(

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
except Exception as e:
import logging
...
except Exception as e:
logging.error(f"Failed to send poll: {e}")
await update.effective_message.reply_text(

await update.effective_message.reply_text(
f"The bot need permission to create Telegram Polls to start a game\n"
f"Please grant those permissions in chat settings."
)
return

message = await context.bot.send_poll(
update.effective_chat.id,
f"@{update.effective_user.username} wants you to record the last game. Please choose your outcome:",
questions,
is_anonymous=False,
allows_multiple_answers=False,
disable_notification=True,
)
await asyncio.gather(
*[
save_poll(
Expand All @@ -42,6 +58,10 @@ async def game(
name=update.effective_chat.title,
)
),
update.effective_message.delete(),
]
)
await try_to_delete_message(
context=context,
chat_id=update.effective_chat.id,
message_id=update.effective_message.id,
)
36 changes: 26 additions & 10 deletions src/handlers/save.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,23 @@
fetch_poll_results,
)
from src.services.draw_result_image import draw_result_image
from src.utils import message_is_poll, is_message_from_group_chat
from src.utils import message_is_poll, is_message_from_group_chat, try_to_delete_message


async def send_result(context, update, game: Game, records: list[Record]):
try:
await context.bot.send_photo(
chat_id=game.chat_id,
photo=await draw_result_image(
records=records, result=game.results, update=update, context=context
),
caption=f"The Game has been saved! Result: {game.results}",
disable_notification=True,
)
except Exception as e:
await update.effective_message.reply_text(
f"Result: {game.results}\nP.S. this bot can send you a result image, allow it to send photos. {e}"
)
Comment on lines +19 to +32
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The send_result function introduces a robust way to communicate game results. Consider logging the exception caught when sending a photo fails to aid in debugging and understanding the specific issues encountered.

+ import logging
  ...
  except Exception as e:
+     logging.error(f"Failed to send result photo: {e}")
      await update.effective_message.reply_text(

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
async def send_result(context, update, game: Game, records: list[Record]):
try:
await context.bot.send_photo(
chat_id=game.chat_id,
photo=await draw_result_image(
records=records, result=game.results, update=update, context=context
),
caption=f"The Game has been saved! Result: {game.results}",
disable_notification=True,
)
except Exception as e:
await update.effective_message.reply_text(
f"Result: {game.results}\nP.S. this bot can send you a result image, allow it to send photos. {e}"
)
import logging
async def send_result(context, update, game: Game, records: list[Record]):
try:
await context.bot.send_photo(
chat_id=game.chat_id,
photo=await draw_result_image(
records=records, result=game.results, update=update, context=context
),
caption=f"The Game has been saved! Result: {game.results}",
disable_notification=True,
)
except Exception as e:
logging.error(f"Failed to send result photo: {e}")
await update.effective_message.reply_text(
f"Result: {game.results}\nP.S. this bot can send you a result image, allow it to send photos. {e}"
)



async def _pass_checks(
Expand Down Expand Up @@ -46,8 +62,9 @@ async def _pass_checks(
return False

if update.effective_user.id != poll_data.creator_id:
user = msg_with_poll.from_user.username if msg_with_poll.from_user.username else msg_with_poll.from_user.first_name
await update.effective_message.reply_text(
f"You are not the creator of the game! Only @{poll_data['creator_username']} can stop this poll."
f"You are not the creator of the game! Only @{user} can stop this poll."
)
return False

Expand Down Expand Up @@ -93,16 +110,15 @@ async def save(
await asyncio.gather(
save_game(game),
*(save_record(record) for record in records),
context.bot.delete_message(chat_id=game.chat_id, message_id=game.poll_id),
update.effective_message.delete(),
context.bot.send_photo(
try_to_delete_message(
context=context, chat_id=game.chat_id, message_id=game.poll_id
),
try_to_delete_message(
context=context,
chat_id=game.chat_id,
photo=await draw_result_image(
records=records, result=game.results, update=update, context=context
),
caption="The Game has been saved!",
disable_notification=True,
message_id=update.effective_message.id,
),
send_result(context=context, update=update, game=game, records=records),
)
else:
await update.effective_message.reply_text(
Expand Down
19 changes: 14 additions & 5 deletions src/handlers/start.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
from telegram import Update
from telegram.ext import ContextTypes

from src.config import AppConfig

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Inform user about what this bot can do"""

async def start(
update: Update, context: ContextTypes.DEFAULT_TYPE, config: AppConfig = AppConfig()
) -> None:
"""Inform user about what this bot can do"""
await update.message.reply_text(
"Hi this bot will help you to gather statistics about your games. Add it to chat "
"with your friends and start a game by /game command. After game is finished, "
"stop the poll by /save command. You can also /help to get more info."
"Hi this bot will help you to gather statistics about your games. Add it to a chat "
"with your friends and start a game by **/game** command. After game is finished, "
"stop the poll by **/save** command. You can also /help to get more info."
"**Hint**: Give the bot admin rights to delete messages and it will automatically clean up after itself.\n"
"Feel free to contribute to the project: [GitHub]("
"https://github.com/Alex-Kopylov/Secret-Hitler-Telegram-Bot-Statistic-Collector)\n"
"Please report any issues to [GitHub Issues]("
"https://github.com/Alex-Kopylov/Secret-Hitler-Telegram-Bot-Statistic-Collector/issues)",
parse_mode="Markdown",
)
8 changes: 4 additions & 4 deletions src/sql/init.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
-- Create table for players
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY NOT NULL UNIQUE, -- Telegram user id
username TEXT NOT NULL, -- Telegram username
username TEXT, -- Telegram username
first_name TEXT, -- Telegram first name
full_name TEXT, -- Telegram full name
last_name TEXT, -- Telegram last name
Expand Down Expand Up @@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS games (
playroom_id INTEGER, -- Telegram chat id
creator_id INTEGER NOT NULL, -- Telegram user id who created the game
time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
result TEXT, -- ["Hitler Canceler", "Fascist Law", "Hitler Death", "Liberal Law"]
result TEXT, -- ["Hitler Chancellor", "Fascist Law", "Hitler Death", "Liberal Law"]
FOREIGN KEY (creator_id) REFERENCES players(id),
FOREIGN KEY (playroom_id) REFERENCES playrooms(id)
);
Expand All @@ -43,7 +43,7 @@ CREATE TABLE IF NOT EXISTS records (
player_id INTEGER NOT NULL,
playroom_id INTEGER, -- Telegram chat id
game_id INTEGER NOT NULL, -- Telegram poll id
role TEXT NOT NULL, -- [HC, HD, HL, FL, LL, LW, FW] # TODO: to int category
role TEXT NOT NULL, -- [HC, DH, HL, FL, LL, LW, FW] # TODO: to int category
FOREIGN KEY (player_id) REFERENCES players(id),
FOREIGN KEY (creator_id) REFERENCES players(id),
FOREIGN KEY (game_id) REFERENCES games(id),
Expand All @@ -57,7 +57,7 @@ CREATE TABLE IF NOT EXISTS polls (
chat_id INTEGER NOT NULL, -- Corresponding playroom id from the playrooms table
chat_name TEXT NOT NULL, -- Name of the chat from the playroom
creator_id INTEGER NOT NULL, -- Id of the player who created the poll
creator_username TEXT NOT NULL, -- Username of the player who created the poll
creator_username TEXT, -- Username of the player who created the poll
creation_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (message_id) REFERENCES games(id),
FOREIGN KEY (chat_id) REFERENCES playrooms(id),
Expand Down
10 changes: 9 additions & 1 deletion src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,12 @@ def message_is_poll(msg: Message) -> bool:

def is_message_from_group_chat(msg: Message) -> bool:
"""Check if a message is from a group chat"""
return msg.chat.type in ("group", "supergroup")
return msg.chat.type == "group" or msg.chat.type == "supergroup"


async def try_to_delete_message(context, chat_id, message_id):
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception as e:
return
Comment on lines +14 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider logging exceptions in try_to_delete_message to aid in debugging.

  except Exception as e:
+     logging.error(f"Failed to delete message: {e}")
      return

Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
async def try_to_delete_message(context, chat_id, message_id):
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception as e:
return
async def try_to_delete_message(context, chat_id, message_id):
try:
await context.bot.delete_message(chat_id=chat_id, message_id=message_id)
except Exception as e:
logging.error(f"Failed to delete message: {e}")
return