diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d894562 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +__pycache__ +.venv +venv \ No newline at end of file diff --git a/.github/workflows/action.yml b/.github/workflows/deploy.yml similarity index 78% rename from .github/workflows/action.yml rename to .github/workflows/deploy.yml index 07df0b0..d0d98cc 100644 --- a/.github/workflows/action.yml +++ b/.github/workflows/deploy.yml @@ -30,6 +30,10 @@ jobs: steps: - name: SSH into production server and deploy uses: appleboy/ssh-action@master + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }} + DEVELOPER_CHAT_ID: ${{ secrets.DEVELOPER_CHAT_ID }} + SQLITE_DB_FILE_PATH: ${{ vars.SQLITE_DB_FILE_PATH }} with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} @@ -39,3 +43,5 @@ jobs: cd ./_work/${{ env.REPO_NAME }}/${{ env.REPO_NAME }} docker compose down docker compose up -d + docker compose ps + envs: TELEGRAM_BOT_TOKEN,DEVELOPER_CHAT_ID,SQLITE_DB_FILE_PATH \ No newline at end of file diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml new file mode 100644 index 0000000..2ca6182 --- /dev/null +++ b/.github/workflows/develop.yml @@ -0,0 +1,47 @@ +name: CI/CD with Docker Compose +env: + REPO_NAME: ${{ github.event.repository.name }} + +on: + push: + branches: + - develop + +jobs: + build: + runs-on: [self-hosted] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: executing remote ssh commands using password + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + key: ${{ secrets.PRIVATE_KEY }} + script: | + cd ./_work/${{ env.REPO_NAME }}/${{ env.REPO_NAME }} + docker compose build --no-cache + + deploy: + needs: [build] + runs-on: [self-hosted] + steps: + - name: SSH into production server and deploy + uses: appleboy/ssh-action@master + env: + TELEGRAM_BOT_TOKEN: ${{ secrets.DEV_TELEGRAM_BOT_TOKEN }} + DEVELOPER_CHAT_ID: ${{ secrets.DEVELOPER_CHAT_ID }} + SQLITE_DB_FILE_PATH: ${{ vars.SQLITE_DB_FILE_PATH }} + with: + host: ${{ secrets.HOST }} + username: ${{ secrets.USERNAME }} + password: ${{ secrets.PASSWORD }} + key: ${{ secrets.PRIVATE_KEY }} + script: | + cd ./_work/${{ env.REPO_NAME }}/${{ env.REPO_NAME }} + docker compose down + docker compose up -d + docker compose ps + envs: TELEGRAM_BOT_TOKEN,DEVELOPER_CHAT_ID,SQLITE_DB_FILE_PATH \ No newline at end of file diff --git a/README.md b/README.md index 14ecc49..46cbb01 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ To host your own instance of the bot, follow these steps: ```dotenv TELEGRAM_BOT_TOKEN=your_telegram_bot_token DEVELOPER_CHAT_ID=your_chat_id_for_getting_logs_and_errors# optional -SQLITE_DB_FILE=database/db.sqlite# or any other path that you prefer +SQLITE_DB_FILE_PATH=db.sqlite# or any other path that you prefer ``` Please note that you need to create a new bot using the [BotFather](https://t.me/BotFather) and get the token for diff --git a/docker-compose.yaml b/docker-compose.yaml index 7e6ce7e..0432d1f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,13 +4,16 @@ services: bot: build: . environment: - - SQLITE_DB_FILE=/database/db.sqlite -# - PYTHONPATH=/bot + - SQLITE_DB_FILE_PATH=${SQLITE_DB_FILE_PATH} + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - DEVELOPER_CHAT_ID=${DEVELOPER_CHAT_ID} volumes: - sqlite_data:/database + entrypoint: ["/bin/sh", "-c"] command: - - python -m sqlite3 ${SQLITE_DB_FILE} < database/init.sql - entrypoint: python src/__main__.py + - | + python -m sqlite3 ${SQLITE_DB_FILE_PATH} < src/sql/init.sql + python src/__main__.py restart: always sqlitebrowser: @@ -20,12 +23,13 @@ services: environment: - PUID=1000 - PGID=1000 - - TZ=UTC + - TZ=GMT+4 - ENABLE_UWSGI=true volumes: - sqlite_data:/database profiles: - sqlitebrowser + restart: unless-stopped volumes: sqlite_data: \ No newline at end of file diff --git a/src/__main__.py b/src/__main__.py index e2cb0bc..f258332 100644 --- a/src/__main__.py +++ b/src/__main__.py @@ -5,9 +5,6 @@ import logging import traceback -from telegram import ( - Update, -) from telegram.ext import ( Application, ) @@ -17,27 +14,27 @@ from src.db import close_db from src.get_handlers import get_handlers -logging.basicConfig( - format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", level=logging.INFO -) -logger = logging.getLogger() - def main() -> None: if not config.TELEGRAM_BOT_TOKEN: - raise ValueError("TELEGRAM_BOT_TOKEN env variable" "wasn't porpoused.") + raise ValueError("TELEGRAM_BOT_TOKEN env variable" "wasn't purposed.") application = Application.builder().token(config.TELEGRAM_BOT_TOKEN).build() application.add_handlers(get_handlers()) application.add_error_handler(handlers.error_handler) # send a message to the developer when the bot is ready - application.run_polling(allowed_updates=Update.ALL_TYPES) + application.run_polling() if __name__ == "__main__": try: main() except Exception: + logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=logging.INFO, + ) + logger = logging.getLogger() logger.warning(traceback.format_exc()) finally: close_db() diff --git a/src/callbacks/receive_poll_answer.py b/src/callbacks/receive_poll_answer.py index c928a95..37d1668 100644 --- a/src/callbacks/receive_poll_answer.py +++ b/src/callbacks/receive_poll_answer.py @@ -1,21 +1,40 @@ +import logging + from telegram import Update from telegram.ext import ContextTypes +from src.data_models.Player import Player +from src.services.db_service import save_player + async def receive_poll_answer( update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: + # TODO save polls to db and query them instead saving them to bot_data """Summarize a users poll vote""" answer = update.poll_answer - if not answer.option_ids: - return # It's retake poll action, ignore it + if not answer.option_ids: # Poll retract, delete previous vote + del context.bot_data[answer.poll_id]["results"][update.effective_user.id] + return if context.bot_data: answered_poll = context.bot_data[answer.poll_id] user_id = update.effective_user.id result = answered_poll["questions"][answer.option_ids[0]] context.bot_data[answer.poll_id]["results"][user_id] = result + await save_player( + Player( + telegram_user_id=user_id, + username=update.effective_user.username, + first_name=update.effective_user.first_name, + full_name=update.effective_user.full_name, + last_name=update.effective_user.last_name, + is_bot=update.effective_user.is_bot, + language_code=update.effective_user.language_code, + ) + ) else: - # failed to save poll answer. - # Ussually happens for polls that are sent to the bot before it started and not updated - # TODO save polls to db and query them instead saving them to bot_data - return + logging.error( + "Failed to save poll answer. Usually happens for polls that are sent to the bot before it " + "started and not updated", + exc_info=True, + ) diff --git a/src/config.py b/src/config.py index ae1a2e3..803cad6 100644 --- a/src/config.py +++ b/src/config.py @@ -7,11 +7,11 @@ load_dotenv() -TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") -DEVELOPER_CHAT_ID = os.getenv("DEVELOPER_CHAT_ID", "") +TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", None) +DEVELOPER_CHAT_ID = os.getenv("DEVELOPER_CHAT_ID", None) # Database constants -SQLITE_DB_FILE = os.getenv("SQLITE_DB_FILE", Path().resolve() / "database/db.sqlite") +SQLITE_DB_FILE_PATH = os.getenv("SQLITE_DB_FILE_PATH", Path().resolve() / "db.sqlite") DATE_FORMAT = "%d.%m.%Y %H:%M:%S" # Game constants diff --git a/src/data_models/Game.py b/src/data_models/Game.py index af4cfff..7540e4b 100644 --- a/src/data_models/Game.py +++ b/src/data_models/Game.py @@ -1,10 +1,24 @@ from datetime import datetime from typing import Literal -from pydantic import BaseModel +from pydantic import BaseModel, field_validator class Game(BaseModel): - playroom_id: int - end_time: datetime - result: Literal["Hitler Canceler", "Fascist Law", "Hitler Death", "Liberal Law"] + poll_id: int + chat_id: int + results: dict # Literal["Hitler Canceler", "Fascist Law", "Hitler Death", "Liberal Law"] + creator_id: int + + @field_validator("results", mode="after") + @classmethod + def validate_results(cls, v: dict) -> Literal["CH", "DH", "FW", "LW"]: + outcomes = set(v.values()) + if "I'm Canceler Hitler" in outcomes: + return "CH" + if "I'm Dead Hitler" in outcomes: + return "DH" + if "I'm Liberal Winner" in outcomes: + return "LW" + if "I'm Fascistic Winner" in outcomes: + return "FW" diff --git a/src/data_models/Player.py b/src/data_models/Player.py index 332c9a2..0a6cd65 100644 --- a/src/data_models/Player.py +++ b/src/data_models/Player.py @@ -1,11 +1,18 @@ from typing import Optional -from pydantic import BaseModel +from pydantic import BaseModel, field_validator class Player(BaseModel): telegram_user_id: int username: str - first_name: Optional[str] = None - last_name: Optional[str] = None - is_bot: bool = False + first_name: Optional[str | None] + full_name: Optional[str | None] + last_name: Optional[str | None] + is_bot: Optional[bool] = False + language_code: Optional[str | None] + + @field_validator("is_bot", mode="after") + @classmethod + def validate_bot(cls, v: bool) -> str: + return "TRUE" if v else "FALSE" # sqlite3 does not support boolean type diff --git a/src/data_models/Record.py b/src/data_models/Record.py index 770ccfb..4829258 100644 --- a/src/data_models/Record.py +++ b/src/data_models/Record.py @@ -30,4 +30,6 @@ def shorten_role(cls, v: str) -> Literal["CH", "DH", "HL", "LW", "LL", "FW", "FL case "I'm Fascistic Loser": return "FL" case _: - raise ValueError(f"Invalid role '{v}' for Record. Role must be one of {config.GAME_POLL_OUTCOMES}") + raise ValueError( + f"Invalid role '{v}' for Record. Role must be one of {config.GAME_POLL_OUTCOMES}" + ) diff --git a/src/db.py b/src/db.py index b4b2e84..2ea53c9 100644 --- a/src/db.py +++ b/src/db.py @@ -7,7 +7,7 @@ async def get_db() -> aiosqlite.Connection: if not getattr(get_db, "db", None): - db = await aiosqlite.connect(config.SQLITE_DB_FILE) + db = await aiosqlite.connect(config.SQLITE_DB_FILE_PATH) get_db.db = db return get_db.db diff --git a/src/get_handlers.py b/src/get_handlers.py index aea47c0..5abce97 100644 --- a/src/get_handlers.py +++ b/src/get_handlers.py @@ -13,4 +13,4 @@ def get_handlers() -> tuple: CommandHandler("save", handlers.save), # Poll answer handler PollAnswerHandler(receive_poll_answer), - ) \ No newline at end of file + ) diff --git a/src/handlers/error_handler.py b/src/handlers/error_handler.py index 1ad04b9..36cb929 100644 --- a/src/handlers/error_handler.py +++ b/src/handlers/error_handler.py @@ -11,8 +11,6 @@ from telegram.ext import ContextTypes - - async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None: # set a higher logging level for httpx to avoid all GET and POST requests being logged logging.getLogger("httpx").setLevel(logging.WARNING) @@ -24,7 +22,9 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N # traceback.format_exception returns the usual python message about an exception, but as a # list of strings rather than a single string, so we have to join them together. - tb_list = traceback.format_exception(None, context.error, context.error.__traceback__) + tb_list = traceback.format_exception( + None, context.error, context.error.__traceback__ + ) tb_string = "".join(tb_list) # Build the message with some markup and additional information about what happened. @@ -38,8 +38,13 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N f"
context.user_data = {html.escape(str(context.user_data))}\n\n" f"
{html.escape(tb_string)}" ) - - # Finally, send the message - await context.bot.send_message( - chat_id=config.DEVELOPER_CHAT_ID, text=message, parse_mode=ParseMode.HTML - ) \ No newline at end of file + if config.DEVELOPER_CHAT_ID: + # Finally, send the message + await context.bot.send_message( + chat_id=config.DEVELOPER_CHAT_ID, text=message, parse_mode=ParseMode.HTML + ) + else: + logger.error( + "DEVELOPER_CHAT_ID env variable wasn't purposed. Please set it to your chat id if you want to " + "receive error messages in telegram chat." + ) diff --git a/src/handlers/game.py b/src/handlers/game.py index 1caddcc..75d0aee 100644 --- a/src/handlers/game.py +++ b/src/handlers/game.py @@ -1,6 +1,8 @@ from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ContextTypes from src import config +from src.data_models.Playroom import Playroom +from src.services.db_service import save_playroom from src.utils import message_is_poll, is_message_from_group_chat @@ -30,5 +32,9 @@ async def game(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: "results": {}, } } - + await save_playroom( + Playroom( + telegram_chat_id=update.effective_chat.id, name=update.effective_chat.title + ) + ) context.bot_data.update(game_metadata) diff --git a/src/handlers/save.py b/src/handlers/save.py index 38987fc..c67db4d 100644 --- a/src/handlers/save.py +++ b/src/handlers/save.py @@ -3,10 +3,11 @@ from telegram import Update from telegram.ext import ContextTypes +from src.data_models.Game import Game from src.data_models.Record import Record from src.utils import message_is_poll, is_message_from_group_chat from src import db -from src.services.db_service import save_record +from src.services.db_service import save_record, save_game async def _pass_checks( @@ -88,7 +89,14 @@ async def save(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: for player_id, result in poll_data["results"].items() ] ) - + await save_game( + Game( + poll_id=poll_data["message_id"], + chat_id=poll_data["chat_id"], + creator_id=poll_data["creator_id"], + results=poll_data["results"].copy(), + ) + ) else: await update.effective_message.reply_text( "Something went wrong. Can't process your request." diff --git a/src/services/db_service.py b/src/services/db_service.py index 954ee63..e35fa18 100644 --- a/src/services/db_service.py +++ b/src/services/db_service.py @@ -1,3 +1,9 @@ +import logging +import sqlite3 + +from src.data_models.Game import Game +from src.data_models.Player import Player +from src.data_models.Playroom import Playroom from src.data_models.Record import Record from src.db import execute @@ -14,3 +20,45 @@ async def save_record(record: Record) -> None: record.role, ), ) + + +async def save_playroom(playroom: Playroom) -> None: + """Add a game room to the bot_data""" + try: + await execute( + "INSERT INTO playrooms (id, name) VALUES (?, ?)", + (playroom.telegram_chat_id, playroom.name), + ) + except sqlite3.IntegrityError: + logging.info(f"Playroom {playroom.name} already exists in the database") + + +async def save_player(player: Player) -> None: + """Add a player to the bot_data""" + try: + await execute( + "INSERT INTO players (id, username, first_name, full_name, last_name, is_bot, language_code) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + ( + player.telegram_user_id, + player.username, + player.first_name, + player.full_name, + player.last_name, + player.is_bot, + player.language_code, + ), + ) + except sqlite3.IntegrityError: + logging.info(f"Player {player.username} already exists in the database") + + +async def save_game(game: Game) -> None: + """Add a game to the bot_data""" + try: + await execute( + "INSERT INTO games (id, playroom_id, creator_id, result) VALUES (?, ?, ?, ?)", + (game.poll_id, game.chat_id, game.creator_id, game.results), + ) + except sqlite3.IntegrityError: + logging.info(f"Game {game.poll_id} already exists in the database") diff --git a/database/init.sql b/src/sql/init.sql similarity index 88% rename from database/init.sql rename to src/sql/init.sql index 1e107a7..d088c52 100644 --- a/database/init.sql +++ b/src/sql/init.sql @@ -3,7 +3,10 @@ CREATE TABLE IF NOT EXISTS players ( id INTEGER PRIMARY KEY NOT NULL UNIQUE, -- Telegram user id username TEXT NOT NULL, -- Telegram username first_name TEXT, -- Telegram first name - last_name TEXT -- Telegram last name + full_name TEXT, -- Telegram full name + last_name TEXT, -- Telegram last name + is_bot TEXT NOT NULL DEFAULT 'FALSE', -- Telegram is_bot + language_code TEXT -- Telegram language code ); -- Create table for playrooms @@ -27,8 +30,7 @@ CREATE TABLE IF NOT EXISTS games ( id INTEGER PRIMARY KEY NOT NULL UNIQUE, -- Telegram poll id playroom_id INTEGER, -- Telegram chat id creator_id INTEGER NOT NULL, -- Telegram user id who created the game - start_time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, - end_time DATETIME, + time DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL, result TEXT, -- ["Hitler Canceler", "Fascist Law", "Hitler Death", "Liberal Law"] FOREIGN KEY (creator_id) REFERENCES players(id), FOREIGN KEY (playroom_id) REFERENCES playrooms(id) @@ -46,4 +48,6 @@ CREATE TABLE IF NOT EXISTS records ( FOREIGN KEY (creator_id) REFERENCES players(id), FOREIGN KEY (game_id) REFERENCES games(id), FOREIGN KEY (playroom_id) REFERENCES playrooms(id) -); \ No newline at end of file +); + +.quit \ No newline at end of file