diff --git a/.gitignore b/.gitignore index bec789a..9a3ef9b 100644 --- a/.gitignore +++ b/.gitignore @@ -131,7 +131,19 @@ dmypy.json # List of user ids & answers *_list.txt *_response.txt +*_file.txt *_patterns.txt # Session journals -*.session* \ No newline at end of file +*.session* + +# SQLite +*.db + +# Bucket objects +birthdays/ +*.jpg +*.png + +# Dynaconf +settings.toml \ No newline at end of file diff --git a/Containerfile b/Containerfile index 766208a..2da05e9 100644 --- a/Containerfile +++ b/Containerfile @@ -2,7 +2,8 @@ FROM python:3.9-alpine as builder WORKDIR /opt -COPY ["bot.py", "/opt/"] +ADD ["/src/", "/opt/"] + COPY requirements.txt requirements.txt RUN pip3 install --no-cache-dir -r requirements.txt diff --git a/bot.py b/bot.py deleted file mode 100644 index 9256e31..0000000 --- a/bot.py +++ /dev/null @@ -1,184 +0,0 @@ -import asyncio -import logging -import os -import random -from pathlib import Path -from typing import List - -from telethon import TelegramClient, events - -# Environment variables -API_ID = os.environ["API_ID"] -API_HASH = os.environ["API_HASH"] -SESSION = os.environ["SESSION"] - -CLIENT = TelegramClient(SESSION, API_ID, API_HASH) - -# Users ID file -CIRCULATION_IDS_FILE = Path("circulation_ids_list.txt") - -# Response -CIRCULATION_RESPONSE_FILE = Path("circulation_response.txt") - -# New Year patterns people use to congratulation someone -NEW_YEAR_PATTERNS_FILE = Path("new_year_patterns.txt") - -# Logging -logging.basicConfig( - format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO -) -logging.basicConfig( - format="%(asctime)s - %(levelname)s - %(message)s", level=logging.ERROR -) - - -def load_ids_from_files(file: Path) -> List[int]: - """ - Load user ids from the files - Try to load from file, if exception caught, send message about err - Also convert to int to compare with ids from Telegram - :return: - """ - try: - with open(file, "r") as users_ids_file: - result = [int(u_ids) for u_ids in users_ids_file.read().split()] - logging.info( - f"Loaded ids from the {users_ids_file.name} done successfully." - ) - return result - except FileNotFoundError as file_not_found_err: - logging.error(file_not_found_err) - - -CIRCULATION_IDS = load_ids_from_files(CIRCULATION_IDS_FILE) - - -def load_responses_from_files(file: Path) -> str: - """ - Load responses from the files - Try to load from file, if exception caught, send message about err - :return: - """ - try: - with open(file, "r") as response_file: - result = response_file.read() - logging.info( - f"Loaded response from the {response_file.name} done successfully." - ) - return result - except FileNotFoundError as file_not_found_err: - logging.error(file_not_found_err) - - -CIRCULATION_RESPONSE = load_responses_from_files(CIRCULATION_RESPONSE_FILE) - - -def load_patterns_from_files(file: Path) -> List: - """ - Load patterns from the files - Try to load from file, if exception caught, send message about err - :return: - """ - try: - with open(file, encoding="UTF-8") as pattern_file: - result = [line.rstrip() for line in pattern_file] - logging.info( - f"Loaded patterns from the {pattern_file.name} done successfully." - ) - return result - except FileNotFoundError as file_not_found_err: - logging.error(file_not_found_err) - - -NEW_YEAR_PATTERNS = load_patterns_from_files(NEW_YEAR_PATTERNS_FILE) - - -async def show_selected_users(): - async for dialog in CLIENT.iter_dialogs(): - if dialog.id in CIRCULATION_IDS: - logging.info(f"Selected username: {dialog.name}; ID: {dialog.id}") - - -async def filter_f(event) -> bool: - for word in NEW_YEAR_PATTERNS: - if word in str(event.raw_text).lower(): - return True - else: - return False - - -@CLIENT.on(events.NewMessage(from_users=CIRCULATION_IDS, func=filter_f)) -async def reply_to_congratulations(event): - user_data = await event.client.get_entity(event.from_id) - logging.info( - f"Contact: {user_data.contact} - " - f"first name: {user_data.first_name} - " - f"ID: {user_data.id} - " - f"sent congratulation: {event.message.message}" - ) - async with CLIENT.action(user_data.id, "typing"): - await asyncio.sleep(random.randrange(5, 10)) - await event.reply(event.message.message) - - -async def send_message_template( - user_data, event, start_range, end_range, response_type -): - logging.info( - f"Contact: {user_data.contact} - " - f"first name: {user_data.first_name} - " - f"ID: {user_data.id} - " - f"sent message: {event.message.message}" - ) - - logging.info("Waiting for response...") - - -# TODO send always -# async with CLIENT.action(user_data.id, "typing"): -# await asyncio.sleep(random.randrange(start_range, end_range)) -# await CLIENT.send_message( -# user_data.id, -# f""" -# Hello, {user_data.first_name}. \n -# **This message was sent automatically.** \n -# """, -# ) -# await CLIENT.send_message(user_data.id, response_type) -# logging.info(f"Response was sent to {user_data.first_name}.") - - -@CLIENT.on(events.NewMessage(incoming=True, from_users=CIRCULATION_IDS)) -async def response_to_group(event): - await show_selected_users() - - user_data = await event.client.get_entity(event.from_id) - - logging.info(f"Raw sender data: {user_data}") - - try: - if user_data.id in CIRCULATION_IDS: - await send_message_template(user_data, event, 5, 10, CIRCULATION_RESPONSE) - elif not user_data.contact: - logging.info("Looks like someone unfamiliar is on the line.") - logging.info( - f"Contact: {user_data.contact} - " - f"first name: {user_data.first_name} - " - f"ID: {user_data.id} - " - f"sent message: {event.message.message}" - ) - except ValueError as val_err: - logging.error(f"Sender is {user_data.first_name}.") - logging.error(val_err) - except TypeError as type_err: - logging.error("That maybe sticker was sent, not text.") - logging.error(f"Sender is {user_data.first_name}.") - logging.error(type_err) - except BaseException as base_exception: - logging.error(f"Sender is {user_data.first_name}.") - logging.error(base_exception) - - -if __name__ == "__main__": - CLIENT.start() - CLIENT.run_until_disconnected() diff --git a/docker-compose.yaml b/docker-compose.yaml index d6d6bc5..e359ebe 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,15 +5,20 @@ services: container_name: herold_bot image: h0d0user/herold_bot:latest restart: always - network_mode: host + networks: + - herold_net environment: - API_ID=YOURAPIID - API_HASH=YOURAPIHASH - SESSION=YOURSESSION volumes: - - "/root/herold_bot/circulation_ids_list.txt:/opt/circulation_ids_list.txt" - - "/root/herold_bot/circulation_response.txt:/opt/circulation_response.txt" - - "/root/herold_bot/new_year_patterns.txt:/opt/new_year_patterns.txt" + - "/root/herold_bot/herold_database.db:/opt/herold_database.db" + - "/root/herold_bot/congrats_list.txt:/opt/congrats_list.txt" - "/root/herold_bot/JustTalk.session:/opt/JustTalk.session" + - "/root/herold_bot/settings.toml:/opt/settings.toml" + volumes: - herold_bot: \ No newline at end of file + herold_volume: + +networks: + herold_net: diff --git a/requirements.txt b/requirements.txt index 1b7af3c..e56ff39 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,7 @@ black isort -telethon~=1.25.4 \ No newline at end of file +boto3~=1.26.114 +dynaconf~=3.1.12 +telethon~=1.25.4 +SQLAlchemy~=2.0.7 +botocore~=1.29.114 \ No newline at end of file diff --git a/src/bot.py b/src/bot.py new file mode 100644 index 0000000..ac8cb12 --- /dev/null +++ b/src/bot.py @@ -0,0 +1,86 @@ +import asyncio +import logging +import os +import random +import time +from datetime import datetime +from pathlib import Path +from typing import List + +from telethon import TelegramClient + +import bucket +import db + +# Environment variables +API_ID = os.environ["API_ID"] +API_HASH = os.environ["API_HASH"] +SESSION = os.environ["SESSION"] + +client = TelegramClient(SESSION, API_ID, API_HASH) + +# Logging +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO +) +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.ERROR +) + + +def load_congrats_from_file(file: Path) -> List[str]: + """ + Load congrats from the file + Try to load from file, if exception caught, send message about err + :return: + """ + try: + with open(Path(file), "r", encoding="utf-8") as file: + result = file.readlines() + logging.info(f"Uploaded response from the {file.name} done successfully.") + return result + except FileNotFoundError as file_not_found_err: + logging.error(f"Err while load file - {file_not_found_err}") + return None + + +CONGRATULATIONS = load_congrats_from_file(Path("congrats_list.txt")) + + +async def send_congratulations(): + """ + Check if someone has a birthday on this date then send congratulation + Wait for one day to check date + :return: + """ + current_date = datetime.today().strftime("%m.%d") + if db.get_tg_id(current_date) is None: + logging.info(f"Today - {current_date} - are no one to congratulate") + time.sleep(86400) + else: + user_data = await client.get_entity(db.get_tg_id(current_date)) + logging.info( + f"Today - {current_date} - going to congratulate {user_data.first_name} - {user_data.username}" + ) + await client.send_message( + db.get_tg_id(current_date), random.choice(CONGRATULATIONS) + ) + async with client.action(user_data.id, "typing"): + await asyncio.sleep(random.randrange(2, 5)) + await client.send_file( + db.get_tg_id(current_date), + bucket.download_file_from_bucket("birthdays", Path("/home/")), + ) + time.sleep(400) + bucket.prune_directory(Path("/home/")) + time.sleep(86000) + + +async def main(): + while True: + await send_congratulations() + + +if __name__ == "__main__": + with client: + client.loop.run_until_complete(main()) diff --git a/src/bucket.py b/src/bucket.py new file mode 100644 index 0000000..422e29c --- /dev/null +++ b/src/bucket.py @@ -0,0 +1,110 @@ +import logging +import os +import random +from pathlib import Path + +import boto3 +import botocore + +import dynaconfig + +session = boto3.session.Session() + +BUCKET_NAME = dynaconfig.settings["BUCKET_NAME"] +ENDPOINT_URL = dynaconfig.settings["ENDPOINT_URL"] +REGION_NAME = dynaconfig.settings["REGION_NAME"] +AWS_ACCESS_KEY_ID = dynaconfig.settings["AWS_ACCESS_KEY_ID"] +AWS_SECRET_ACCESS_KEY = dynaconfig.settings["AWS_SECRET_ACCESS_KEY"] + +client = session.client( + "s3", + endpoint_url=ENDPOINT_URL, + config=botocore.config.Config(s3={"addressing_style": "virtual"}), + region_name=REGION_NAME, + aws_access_key_id=AWS_ACCESS_KEY_ID, + aws_secret_access_key=AWS_SECRET_ACCESS_KEY, +) + + +# Logging +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO +) +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.ERROR +) + + +def filter_conf(dir_to_filter): + """ + Configure what to filter from the list files of directory + :param dir_to_filter: + :return: + """ + + def check_dir(directory: str) -> bool: + """ + Check the needed directory + :param directory: + :return: + """ + return True if directory.startswith(dir_to_filter) else False + + return check_dir + + +def get_random_object(search_dir: str) -> str: + """ + Get random object from searched directory in bucket + :param search_dir: + :return: + """ + response = client.list_objects(Bucket=BUCKET_NAME) + result = [] + for obj in response["Contents"]: + result.append(obj["Key"]) + + f_c = filter_conf(search_dir) + result = filter(f_c, result) + + return random.choices(list(result)[1:])[0].split("/")[1] + + +def download_file_from_bucket(remote_directory: str, local_directory: Path) -> str: + """ + Download random file from bucket + :param remote_directory: + :param local_directory: + :return: + """ + try: + random_file = get_random_object(remote_directory) + client.download_file(BUCKET_NAME, random_file, local_directory) + logging.info(f"File {random_file} downloaded to f{local_directory}") + logging.info(f"Directory {local_directory} - {os.listdir(local_directory)}") + return random_file + except Exception as err: + logging.error(f"File not downloaded ! - {err}") + return None + + +def prune_directory(directory_to_prune: Path): + """ + Need to prune directory after downloading to evade container enlargement + :param directory_to_prune: + :return: + """ + try: + for file in os.listdir(directory_to_prune): + os.remove(os.path.join(directory_to_prune, file)) + logging.info(f"Directory {directory_to_prune} was pruned !") + except FileNotFoundError as dir_not_found: + logging.error( + f"Directory {directory_to_prune} was not pruned - {dir_not_found}" + ) + except Exception as err: + logging.error(f"Directory {directory_to_prune} was not pruned - {err}") + + +if __name__ == "__main__": + pass diff --git a/src/db.py b/src/db.py new file mode 100644 index 0000000..e68841d --- /dev/null +++ b/src/db.py @@ -0,0 +1,53 @@ +import logging + +import sqlalchemy +from sqlalchemy import exc + +import models + +# Logging +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO +) +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.ERROR +) + + +def connect_to_db(db_name="herold_database.db") -> sqlalchemy.engine.base.Connection: + return sqlalchemy.create_engine(f"sqlite:///{db_name}", echo=False).connect() + + +def create_db(): + try: + models.meta.create_all(connect_to_db()) + except exc.SQLAlchemyError as sql_err: + logging.error(f"Err while creating db - {sql_err}") + + +def get_all_people_from_db(model): + """ + Query all rows from the db + :return: + """ + try: + return connect_to_db().execute(model.birthdays.select()).fetchall() + except exc.SQLAlchemyError as sql_err: + logging.error(f"Err while fetching from {models.birthdays} db - {sql_err}") + + +def get_tg_id(current_date) -> str: + """ + Get telegram ID if today is anyone to congrats + :param current_date: + :return: + """ + for d in get_all_people_from_db(model=models): + if current_date == d[1]: + logging.info(f"{d[1]} = {current_date}") + return d[3] + return None + + +if __name__ == "__main__": + pass diff --git a/src/dynaconfig.py b/src/dynaconfig.py new file mode 100644 index 0000000..524ab92 --- /dev/null +++ b/src/dynaconfig.py @@ -0,0 +1,16 @@ +import logging + +from dynaconf import Dynaconf + +# Logging +logging.basicConfig( + format="%(asctime)s - %(levelname)s - %(message)s", level=logging.INFO +) + +settings = Dynaconf( + settings_files=["settings.toml"], +) + +if __name__ == "__main__": + for data in settings: + logging.info(f"Loaded variable {data}") diff --git a/src/models.py b/src/models.py new file mode 100644 index 0000000..18844b8 --- /dev/null +++ b/src/models.py @@ -0,0 +1,13 @@ +import sqlalchemy + +meta = sqlalchemy.MetaData() + +birthdays = sqlalchemy.Table( + "people", + meta, + sqlalchemy.Column("name", sqlalchemy.String), + sqlalchemy.Column("day_month", sqlalchemy.String), + sqlalchemy.Column("year", sqlalchemy.String), + sqlalchemy.Column("tg_id", sqlalchemy.String), + sqlalchemy.Column("congrats_file_path", sqlalchemy.String), +)