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

ci cd , record games and players to db #5

Merged
merged 21 commits into from
Feb 6, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__
.venv
venv
6 changes: 6 additions & 0 deletions .github/workflows/action.yml → .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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
47 changes: 47 additions & 0 deletions .github/workflows/develop.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
17 changes: 7 additions & 10 deletions src/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@
import logging
import traceback

from telegram import (
Update,
)
from telegram.ext import (
Application,
)
Expand All @@ -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.")
Copy link
Contributor

Choose a reason for hiding this comment

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

Ensure the error message concatenation is intentional. It appears there might be a missing space between "TELEGRAM_BOT_TOKEN env variable" and "wasn't purposed."

- raise ValueError("TELEGRAM_BOT_TOKEN env variable" "wasn't purposed.")
+ raise ValueError("TELEGRAM_BOT_TOKEN env variable wasn't purposed.")

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
raise ValueError("TELEGRAM_BOT_TOKEN env variable" "wasn't purposed.")
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()
31 changes: 25 additions & 6 deletions src/callbacks/receive_poll_answer.py
Original file line number Diff line number Diff line change
@@ -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,
)
6 changes: 3 additions & 3 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 18 additions & 4 deletions src/data_models/Game.py
Original file line number Diff line number Diff line change
@@ -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"
Comment on lines +13 to +24
Copy link
Contributor

Choose a reason for hiding this comment

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

The validate_results method's return type should match the expected type of the results attribute. If results is intended to be a dict, the method's return type and implementation need adjustment. Consider revising the method or the attribute type to ensure consistency.

- def validate_results(cls, v: dict) -> Literal["CH", "DH", "FW", "LW"]:
+ # Adjust the method or the attribute type to ensure consistency.

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
@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"
@field_validator("results", mode="after")
@classmethod
def validate_results(cls, v: dict): # Adjust the method or the attribute type to ensure consistency.
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"

15 changes: 11 additions & 4 deletions src/data_models/Player.py
Original file line number Diff line number Diff line change
@@ -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]
Comment on lines +9 to +13
Copy link
Contributor

Choose a reason for hiding this comment

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

Ensure the use of Optional[str | None] is intentional. Typically, Optional[str] suffices as it already implies None can be a value, making the explicit | None redundant.

- first_name: Optional[str | None]
+ first_name: Optional[str]

Apply this change to full_name, last_name, and language_code as well.


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
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]
first_name: Optional[str]
full_name: Optional[str]
last_name: Optional[str]
is_bot: Optional[bool] = False
language_code: Optional[str]


@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
Comment on lines +15 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.

The validate_bot method's return type is incorrect. It should return bool instead of str to match the field type is_bot. Adjust the method to directly return the boolean value.

- def validate_bot(cls, v: bool) -> str:
+ def validate_bot(cls, v: bool) -> bool:
-     return "TRUE" if v else "FALSE"
+     return v

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
@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
@field_validator("is_bot", mode="after")
@classmethod
def validate_bot(cls, v: bool) -> bool:
return v # sqlite3 does not support boolean type

4 changes: 3 additions & 1 deletion src/data_models/Record.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
)
2 changes: 1 addition & 1 deletion src/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/get_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ def get_handlers() -> tuple:
CommandHandler("save", handlers.save),
# Poll answer handler
PollAnswerHandler(receive_poll_answer),
)
)
21 changes: 13 additions & 8 deletions src/handlers/error_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -38,8 +38,13 @@ async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> N
f"<pre>context.user_data = {html.escape(str(context.user_data))}</pre>\n\n"
f"<pre>{html.escape(tb_string)}</pre>"
)

# Finally, send the message
await context.bot.send_message(
chat_id=config.DEVELOPER_CHAT_ID, text=message, parse_mode=ParseMode.HTML
)
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."
)
8 changes: 7 additions & 1 deletion src/handlers/game.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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)
12 changes: 10 additions & 2 deletions src/handlers/save.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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."
Expand Down
Loading