Skip to content

Commit

Permalink
Bug fixes (#18)
Browse files Browse the repository at this point in the history
* exceptions handling
(permissions), small fixes, ref

* bot commands description

* username errors handling

* you are not poll creator error fix

* Canceler -> Chancellor

* wants you to record the last game -> wants you to record the game
  • Loading branch information
Alex-Kopylov authored Apr 4, 2024
1 parent 449f339 commit 1975e5e
Show file tree
Hide file tree
Showing 14 changed files with 139 additions and 42 deletions.
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
12 changes: 11 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,20 @@
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}"
)
14 changes: 11 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,11 @@ 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:
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,
)
40 changes: 30 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}"
)


async def _pass_checks(
Expand Down Expand Up @@ -46,8 +62,13 @@ 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 +114,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
9 changes: 8 additions & 1 deletion src/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ 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

0 comments on commit 1975e5e

Please sign in to comment.