diff --git a/.github/workflows/build_bot.yml b/.github/workflows/build_bot.yml deleted file mode 100644 index 0b8e810c..00000000 --- a/.github/workflows/build_bot.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Docker Image Bot - -on: - release: - types: [published] - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v2 - - - name: Login to DockerHub - uses: docker/login-action@v1 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push Docker image - uses: docker/build-push-action@v2 - with: - context: . - push: true - tags: iwatkot/maps4fsbot:latest - file: ./docker/Dockerfile_bot - build-args: | - BOT_TOKEN=${{ secrets.BOT_TOKEN }} - TELEGRAM_ADMIN_ID=${{ secrets.TELEGRAM_ADMIN_ID }} \ No newline at end of file diff --git a/.github/workflows/build_webui.yml b/.github/workflows/build_docker.yml similarity index 87% rename from .github/workflows/build_webui.yml rename to .github/workflows/build_docker.yml index 8a47a63b..44b26266 100644 --- a/.github/workflows/build_webui.yml +++ b/.github/workflows/build_docker.yml @@ -1,4 +1,4 @@ -name: Docker Image WebUI +name: Build and push Docker image on: release: @@ -24,4 +24,4 @@ jobs: context: . push: true tags: iwatkot/maps4fs:latest - file: ./docker/Dockerfile_webui \ No newline at end of file + file: ./docker/Dockerfile \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index a4681d1e..fdc633e2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -25,18 +25,6 @@ "env": { "PYTHONPATH": "${workspaceFolder}:${PYTHONPATH}" } - }, - { - "name": "Linux / Mac Bot", - "type": "python", - "request": "launch", - "program": "bot/bot.py", - "console": "integratedTerminal", - "justMyCode": true, - "env": { - "PYTHONPATH": "${workspaceFolder}:${PYTHONPATH}", - "LOG_LEVEL": "DEBUG", - } } ] } \ No newline at end of file diff --git a/bot/bot.py b/bot/bot.py deleted file mode 100644 index 868110a1..00000000 --- a/bot/bot.py +++ /dev/null @@ -1,479 +0,0 @@ -import os -from datetime import datetime - -from aiogram import Bot, Dispatcher, executor, types -from aiogram.dispatcher.filters import Text -from aiogram.types import ( - InlineKeyboardButton, - InlineKeyboardMarkup, - KeyboardButton, - ReplyKeyboardMarkup, -) -from bot_templates import Buttons, Messages -from dotenv import load_dotenv - -import maps4fs as mfs - -logger = mfs.Logger(__name__, level="DEBUG") - -# region constants -GAME_CODES = { - "FS22": "Farming Simulator 22", - "FS25": "Farming Simulator 25", -} -MAP_SIZES = ["2048", "4096", "8192", "16384"] -MAX_HEIGHTS = { - "100": "πŸ€ For flatlands", - "200": "πŸ€ For plains", - "400": "πŸ—» For hills", - "600": "⛰️ For large hills", - "800": "πŸ”οΈ For mountains", -} -# endregion - -# region directories -working_directory = os.getcwd() -maps_directory = os.path.join(working_directory, "maps") -archives_directory = os.path.join(working_directory, "archives") -stats_directory = os.path.join(working_directory, "stats") -stats_file = os.path.join(stats_directory, "stats.txt") -# endregion - -os.makedirs(maps_directory, exist_ok=True) -os.makedirs(archives_directory, exist_ok=True) -logger.info("Working directory: %s", working_directory) - -os.makedirs(stats_directory, exist_ok=True) -logger.info("Stats directory: %s", stats_directory) - -# region environment variables -env_path = os.path.join(working_directory, "bot.env") -if os.path.exists(env_path): - load_dotenv(env_path) -token = os.getenv("BOT_TOKEN") -admin_id = 96970002 -logger.info("Admin ID: %s", admin_id) -if not token: - raise RuntimeError("No token provided.") -# endregion - -bot = Bot(token=token) -dp = Dispatcher(bot=bot) - -# Simple dictionary to store sessions of map generation. -sessions = {} - - -class Session: - """Represents a session of map generation. Stores all the necessary data. - - Args: - telegram_id (int): Telegram ID of the user. - coordinates (tuple[float, float]): Coordinates of the center of the map. - - Attributes: - telegram_id (int): Telegram ID of the user. - timestamp (int): Timestamp of the session creation. - name (str): Name of the session. - coordinates (tuple[float, float]): Coordinates of the center of the map. - distance (int): Distance from the center of the map to the edge. - dem_settings (generate.DemSettings): DEM settings. - """ - - def __init__(self, telegram_id: int, game_code: str): - self.telegram_id = telegram_id - self.timestamp = int(datetime.now().timestamp()) - self.name = f"{self.telegram_id}_{self.timestamp}" - self.map_directory = os.path.join(maps_directory, self.name) - self.game = mfs.Game.from_code(game_code) - self.coordinates = None - self.distance = None - self.max_height = None - - def run(self) -> tuple[str, str]: - """Runs the session and returns paths to the preview and the archive. - - Returns: - tuple[str, str]: Paths to the preview and the archive. - """ - mp = mfs.Map( - self.game, - self.coordinates, - self.distance, - self.map_directory, - blur_seed=5, - max_height=self.max_height, - logger=logger, - ) - mp.generate() - # preview_path = mp.previews()[0] - preview_path = None # Disabled to avoid Docker 137 error (OUT OF MEMORY). - archive_path = mp.pack(os.path.join(archives_directory, self.name)) - return preview_path, archive_path - - -@dp.message_handler(commands=["start"]) -async def start(message: types.Message) -> None: - """Handles the /start command. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - await bot.send_message( - message.from_user.id, - Messages.START.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - ) - - -@dp.message_handler(Text(equals=Buttons.GITHUB.value)) -async def button_github(message: types.Message) -> None: - """Handles the GitHub button. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - await bot.send_message( - message.from_user.id, - Messages.GITHUB.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - disable_web_page_preview=True, - parse_mode=types.ParseMode.MARKDOWN_V2, - ) - - -@dp.message_handler(Text(equals=Buttons.STATISTICS.value)) -async def button_statistics(message: types.Message) -> None: - """Handles the Statistics button. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - try: - with open(stats_file, "r") as file: - lines = file.readlines() - stats = len(lines) - except FileNotFoundError: - stats = 0 - - await bot.send_message( - message.from_user.id, - Messages.STATISTICS.value.format(stats), - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - ) - - if admin_id and admin_id == message.from_user.id: - logger.info("Admin requested stats.") - if not os.path.isfile(stats_file): - logger.info("No stats file found.") - return - # Send the stats file to the admin. - try: - admin_stats = types.InputFile(stats_file) - await bot.send_document(admin_id, admin_stats) - logger.info("Stats file sent to the admin.") - except Exception as e: - logger.error(f"Error during sending stats file to the admin: {repr(e)}") - - -@dp.message_handler(Text(equals=Buttons.COFFEE.value)) -async def button_coffee(message: types.Message) -> None: - """Handles the Buy me a coffee button. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - await bot.send_message( - message.from_user.id, - Messages.COFFEE.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - disable_web_page_preview=True, - parse_mode=types.ParseMode.MARKDOWN_V2, - ) - - -@dp.message_handler(Text(equals=Buttons.GENERATE.value)) -async def button_generate(message: types.Message) -> None: - """Handles the Generate button, creates inline buttons for the game selection. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - buttons = {} - for game_code, game_name in GAME_CODES.items(): - buttons[f"game_code_{game_code}"] = game_name - - await bot.send_message( - message.from_user.id, - Messages.CHOOSE_GAME.value, - reply_markup=await keyboard(buttons), - ) - - -@dp.callback_query_handler(text_contains="game_code_") -async def game_choose(message: types.Message) -> None: - """Handles the catch of the game selection, registers the coordinates handler. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - game_code = message.data.rsplit("_", 1)[-1] - - session = Session(message.from_user.id, game_code) - sessions[message.from_user.id] = session - - dp.register_message_handler(coordinates) - - await bot.send_message( - message.from_user.id, - Messages.ENTER_COORDINATES.value, - reply_markup=await keyboard([Buttons.CANCEL.value]), - parse_mode=types.ParseMode.MARKDOWN_V2, - disable_web_page_preview=True, - ) - - -@dp.message_handler(Text(equals=Buttons.CANCEL.value)) -async def cancel_button(message: types.Message) -> None: - """Handles the Cancel button, returns to the main menu. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - await bot.send_message( - message.from_user.id, - Messages.CANCELLED.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - ) - - -async def coordinates(message: types.Message) -> None: - """Handles the coordinates input, can be accessed only as a next step after the Generate button. - Checks if the coordinates are correct and creates inline buttons for map sizes. - - Args: - message (types.Message): Message, which triggered the handler. - """ - await log_event(message) - - if message.text == Buttons.CANCEL.value: - dp.message_handlers.unregister(coordinates) - await bot.send_message( - message.from_user.id, - Messages.CANCELLED.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - ) - return - - try: - latitude, longitude = message.text.split(",") - latitude = float(latitude.strip()) - longitude = float(longitude.strip()) - except ValueError: - await bot.send_message( - message.from_user.id, - Messages.WRONG_COORDINATES.value, - reply_markup=await keyboard([Buttons.CANCEL.value]), - parse_mode=types.ParseMode.MARKDOWN_V2, - ) - return - - session = sessions.get(message.from_user.id) - if not session: - return - session.coordinates = (latitude, longitude) - - sizes = MAP_SIZES - indicators = ["🟒", "🟒", "🟑", "πŸ”΄"] - buttons = {} - # * Slice sizes because VPS can not handle large images. - for size, indicator in zip(sizes[:2], indicators[:2]): - buttons[f"map_size_{size}"] = f"{indicator} {size} x {size} meters" - - dp.message_handlers.unregister(coordinates) - - await bot.send_message( - message.from_user.id, - Messages.SELECT_MAP_SIZE.value, - reply_markup=await keyboard(buttons), - ) - - -@dp.callback_query_handler(text_contains="map") -async def map_size_callback(callback_query: types.CallbackQuery) -> None: - """Handles the callback from the map size inline buttons, creates inline buttons for max heights. - - Args: - callback_query (types.CallbackQuery): Callback, which triggered the handler. - """ - await log_event(callback_query) - - map_size = int(callback_query.data.rsplit("_", 1)[-1]) - session = sessions.get(callback_query.from_user.id) - if not session: - return - session.distance = int(map_size / 2) - - heights = MAX_HEIGHTS - buttons = {} - for height, description in heights.items(): - buttons[f"max_height_{height}"] = description - - await bot.send_message( - callback_query.from_user.id, - Messages.SELECT_MAX_HEIGHT.value, - reply_markup=await keyboard(buttons), - ) - - -@dp.callback_query_handler(text_contains="max_height_") -async def max_height_callback(callback_query: types.CallbackQuery) -> None: - """Handles the callback from the max height inline buttons, starts the generation process. - Sends the preview and the archive. - - Args: - callback_query (types.CallbackQuery): Callback, which triggered the handler. - """ - await log_event(callback_query) - - max_height = int(callback_query.data.rsplit("_", 1)[-1]) - session = sessions.get(callback_query.from_user.id) - if not session: - return - - session.max_height = max_height - - await bot.send_message( - callback_query.from_user.id, - Messages.GENERATION_STARTED.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - ) - - try: - preview, result = session.run() - except Exception as e: - logger.error(f"Error during generation: {repr(e)}") - return - archive = types.InputFile(result) - # picture = types.InputFile(preview) - # await bot.send_photo(callback_query.from_user.id, picture) - try: - await bot.send_document(callback_query.from_user.id, archive) - except Exception as e: - logger.error(repr(e)) - await bot.send_message( - callback_query.from_user.id, - Messages.FILE_TOO_LARGE.value, - reply_markup=await keyboard(Buttons.MAIN_MENU.value), - ) - - try: - await save_stats(session, "success") - await notify_admin(session, "success") - except Exception as e: - logger.error(f"Error during saving stats and/or notifying admin: {repr(e)}") - - try: - # os.remove(preview) - os.remove(result) - except FileNotFoundError as e: - logger.error(e) - - -async def save_stats(session: Session, status: str) -> None: - """Saves the stats of the session to text file. - - Args: - session (Session): Session to save. - status (str): Result of the session. - """ - entry = ( - f"{session.timestamp} {session.telegram_id} {session.coordinates} " - f"{session.distance} {session.max_height}\n" - ) - with open(stats_file, "a") as file: - file.write(entry) - - logger.info("Stats saved for %s", session.name) - - -async def notify_admin(session: Session, status: str) -> None: - """Notifies the admin about the session. - - Args: - session (Session): Session to notify about. - status (str): Result of the session. - """ - if not admin_id: - logger.info("No admin ID provided, skipping notification.") - return - await bot.send_message( - admin_id, - f"Session started by {session.telegram_id} at {session.timestamp}.", - ) - - logger.info("Admin notified about %s", session.name) - - -async def keyboard( - buttons: list[str] | dict[str, str] -) -> ReplyKeyboardMarkup | InlineKeyboardMarkup: - """Creates a keyboard with buttons depending on the input. - If the input is a list, creates a ReplyKeyboardMarkup. - If the input is a dict, creates an InlineKeyboardMarkup, where keys are callback_data and values are text. - - Args: - buttons (list[str] | dict[str, str]): List or dict of buttons. - - Returns: - ReplyKeyboardMarkup | InlineKeyboardMarkup: Keyboard with buttons. - """ - if isinstance(buttons, list): - keyboard = ReplyKeyboardMarkup( - resize_keyboard=True, - ) - for button in buttons: - keyboard.add(KeyboardButton(button)) - - elif isinstance(buttons, dict): - keyboard = InlineKeyboardMarkup( - row_width=2, - ) - for callback_data, text in buttons.items(): - keyboard.add(InlineKeyboardButton(callback_data=callback_data, text=text)) - - return keyboard - - -async def log_event(data: types.Message | types.CallbackQuery) -> None: - """Logs the event. - - Args: - data (types.Message | types.CallbackQuery): Data, which triggered the handler. - """ - try: - logger.debug( - f"Message from {data.from_user.username} with telegram ID {data.from_user.id}: {data.text}" - ) - except AttributeError: - logger.debug( - f"Callback from {data.from_user.username} with telegram ID {data.from_user.id}: {data.data}" - ) - - -if __name__ == "__main__": - executor.start_polling(dp, allowed_updates=["message", "callback_query"]) diff --git a/bot/bot_templates.py b/bot/bot_templates.py deleted file mode 100644 index 2c408d87..00000000 --- a/bot/bot_templates.py +++ /dev/null @@ -1,60 +0,0 @@ -from enum import Enum - - -class Messages(Enum): - """Messages, which are used in the bot.""" - - START = ( - "Hello, I'm a bot that can generate map templates for Farming Simulator.\n\n" - "To get started, use the menu below." - ) - GITHUB = ( - "Feel free to contribute to the [project on GitHub](https://github.com/iwatkot/maps4fs)\." - ) - COFFEE = ( - "If you like my work, you can [buy me a coffee](https://www.buymeacoffee.com/iwatkot0)\." - ) - - CANCELLED = "The operation has been cancelled." - - CHOOSE_GAME = "Choose the game for which you want to generate a map." - - ENTER_COORDINATES = ( - "Enter the coordinates of the center of the map\." - "The coordinates are latitude and longitude separated by a comma\.\n\n" - "For example: `45\.2602, 19\.8086`\n\n" - "You can obtain them by right\-clicking on the map in [Google Maps](https://www.google.com/maps)\." - ) - WRONG_COORDINATES = ( - "Please enter the coordinates in the correct format\.\n\n" - "For example: `45\.2602, 19\.8086`\n\n" - ) - - SELECT_MAP_SIZE = ( - "Select the size of the map.\n\n" - "🟒 work fine on most devices.\n" - "🟑 require a decent device.\n" - "πŸ”΄ require a powerful device and not recommended.\n\n" - ) - - SELECT_MAX_HEIGHT = ( - "Select the maximum height of the map.\n" - "It's recommended to use smaller values for flatlands and larger values for mountains." - ) - - GENERATION_STARTED = "Map generation has been started. It may take a while." - FILE_TOO_LARGE = "The map is too large to send it via Telegram. Please, lower the map size." - - STATISTICS = "Recently was generated {} maps." - - -class Buttons(Enum): - """Buttons, which are used in the bot menu.""" - - GENERATE = "πŸ—ΊοΈ Generate new map" - GITHUB = "πŸ™ Open on GitHub" - COFFEE = "β˜• Buy me a coffee" - STATISTICS = "πŸ“Š Statistics" - - MAIN_MENU = [GENERATE, GITHUB, COFFEE, STATISTICS] - CANCEL = "❌ Cancel" diff --git a/docker/Dockerfile_webui b/docker/Dockerfile similarity index 79% rename from docker/Dockerfile_webui rename to docker/Dockerfile index 750de7b0..83f914c1 100644 --- a/docker/Dockerfile_webui +++ b/docker/Dockerfile @@ -9,9 +9,8 @@ WORKDIR /usr/src/app COPY data /usr/src/app/data COPY webui /usr/src/app/webui -COPY docker /usr/src/app/docker -RUN pip install -r docker/requirements_webui.txt +RUN pip install -r requirements.txt EXPOSE 8501 diff --git a/docker/Dockerfile_bot b/docker/Dockerfile_bot deleted file mode 100644 index c662127a..00000000 --- a/docker/Dockerfile_bot +++ /dev/null @@ -1,20 +0,0 @@ -FROM python:3.11-slim-buster - -ARG BOT_TOKEN -ENV BOT_TOKEN=$BOT_TOKEN - -# Dependencies for opencv. -RUN apt-get update && apt-get install -y \ - libgl1-mesa-dev \ - libglib2.0-0 - -WORKDIR /usr/src/app - -COPY data /usr/src/app/data -COPY bot /usr/src/app/bot -COPY docker /usr/src/app/docker - -RUN pip install -r docker/requirements_bot.txt - -ENV PYTHONPATH .:${PYTHONPATH} -CMD ["python", "-u", "./bot/bot.py"] \ No newline at end of file diff --git a/docker/requirements_webui.txt b/docker/requirements.txt similarity index 100% rename from docker/requirements_webui.txt rename to docker/requirements.txt diff --git a/docker/requirements_bot.txt b/docker/requirements_bot.txt deleted file mode 100644 index 572bb301..00000000 --- a/docker/requirements_bot.txt +++ /dev/null @@ -1,6 +0,0 @@ -opencv-python -osmnx<2.0.0 -rasterio -python_dotenv -aiogram==2.25.1 -tqdm \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 7169db02..83f4e487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "maps4fs" -version = "0.6.1" +version = "0.6.3" description = "Generate map templates for Farming Simulator from real places." authors = [{name = "iwatkot", email = "iwatkot@gmail.com"}] license = {text = "MIT License"} diff --git a/requirements.txt b/requirements.txt index bd78631e..15ec73ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,6 @@ opencv-python opencv-python-headless osmnx<2.0.0 rasterio -python_dotenv tqdm streamlit maps4fs \ No newline at end of file