diff --git a/.gitignore b/.gitignore index 39c4532..bcc8d4c 100644 --- a/.gitignore +++ b/.gitignore @@ -163,4 +163,5 @@ TASK.md inline_bot.py data/applications.json !data/cache/avatars/.gitkeep -data/cache/avatars/* \ No newline at end of file +data/cache/avatars/* +.vscode \ No newline at end of file diff --git a/api_avatars.py b/api_avatars.py index e4014c1..d162076 100644 --- a/api_avatars.py +++ b/api_avatars.py @@ -1,9 +1,11 @@ -from os import path, sep +from os import makedirs, path, sep from fastapi import FastAPI, HTTPException from fastapi.responses import FileResponse, JSONResponse from starlette.status import HTTP_404_NOT_FOUND from modules.utils import configGet +makedirs(f'{configGet("cache", "locations")}{sep}avatars', exist_ok=True) + app = FastAPI(title="HoloUA Avatars API", docs_url=None, redoc_url=None, version="1.0") @app.get("/check", response_class=JSONResponse, include_in_schema=False) diff --git a/app.py b/app.py index bd365a3..6c40ca1 100644 --- a/app.py +++ b/app.py @@ -4,7 +4,7 @@ from pyrogram.client import Client app = Client("holochecker", bot_token=configGet("bot_token", "bot"), api_id=configGet("api_id", "bot"), api_hash=configGet("api_hash", "bot")) async def isAnAdmin(admin_id): - if admin_id == configGet("owner") or admin_id in configGet("admins"): + if (admin_id == configGet("owner")) or (admin_id in configGet("admins")): return True async for member in app.get_chat_members(configGet("admin_group")): if member.user.id == admin_id: diff --git a/cache/.gitkeep b/cache/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/cache/avatars/.gitkeep b/cache/avatars/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/classes/holo_user.py b/classes/holo_user.py new file mode 100644 index 0000000..e07b2ca --- /dev/null +++ b/classes/holo_user.py @@ -0,0 +1,105 @@ +from app import app, isAnAdmin +from typing import Any, List, Union +from pyrogram.types import User, ChatMember, ChatPrivileges, Chat, Message +from pyrogram.client import Client +from pyrogram.errors import bad_request_400 +from modules.database import col_users, col_context, col_warnings, col_applications, col_sponsorships, col_messages +from modules.logging import logWrite +from modules.utils import configGet, locale, should_quote + +class UserNotFoundError(Exception): + """HoloUser could not find user with such an ID in database""" + def __init__(self, user, user_id): + self.user = user + self.user_id = user_id + super().__init__(f"User of type {type(self.user)} with id {self.user_id} was not found") + +class UserInvalidError(Exception): + """Provided to HoloUser object is not supported""" + def __init__(self, user): + self.user = user + super().__init__(f"Could not find HoloUser by using {type(self.user)} as an input type") + +class HoloUser(): + + def __init__(self, user: Union[List[User], User, ChatMember, int, str]) -> None: + + # Determine input object class and extract id + if isinstance(user, list) and len(user) != 0: + self.id = user[0].id + elif isinstance(user, User): + self.id = user.id + elif isinstance(user, ChatMember): + self.id = user.user.id + elif isinstance(user, int): + self.id = user + elif isinstance(user, str): + try: + self.id = (app.get_users(user)).id # this line requires testing though + except bad_request_400.UsernameNotOccupied: + raise UserInvalidError(user) + except bad_request_400.PeerIdInvalid: + raise UserInvalidError(user) + else: + raise UserInvalidError(user) + + # Find user record in DB + holo_user = col_users.find_one({"user": self.id}) + + if holo_user is None: + raise UserNotFoundError(user=user, user_id=self.id) + + self.db_id = holo_user["_id"] + + self.link = holo_user["link"] + self.label = holo_user["label"] + self.name = holo_user["tg_name"] + self.phone = holo_user["tg_phone"] + self.locale = holo_user["tg_locale"] + self.username = holo_user["tg_username"] + + def set(self, key: str, value: Any) -> None: + """Set attribute data and save it into database + + ### Args: + * `key` (`str`): Attribute to be changed + * `value` (`Any`): Value to set + """ + if not hasattr(self, key): + raise AttributeError() + setattr(self, key, value) + col_users.update_one(filter={"_id": self.db_id}, update={ "$set": { key: value } }, upsert=True) + logWrite(f"Set attribute {key} of user {self.id} to {value}") + + async def message(self, origin: Message, text: Union[str, None] = None, photo: Union[str, None] = None, video: Union[str, None] = None, file: Union[str, None] = None): + new_message = await app.send_message(self.id, text+locale("message_reply_notice", "message")) + await origin.reply_text(locale("message_sent", "message"), quote=should_quote(origin)) + logWrite(f"Admin {origin.from_user.id} sent message '{' '.join(origin.command[2:])}' to {self.id}") + col_messages.insert_one({"origin": {"chat": origin.chat.id, "id": origin.id}, "destination": {"chat": new_message.chat.id, "id": new_message.id}}) + + async def set_label(self, chat: Chat, label: str): + """Set label in destination group + + ### Args: + * app (`Client`): Pyrogram client + * label (`str`): Label you want to set + """ + self.label = label + self.set("label", label) + await app.promote_chat_member(configGet("destination_group"), self.id) + if (not await isAnAdmin(self.id)) and (chat.id == configGet("admin_group")): + await app.set_administrator_title(configGet("destination_group"), self.id, label) + + async def reset_label(self, chat: Chat): + """Reset label in destination group + + ### Args: + * app (`Client`): Pyrogram client + """ + self.label = "" + self.set("label", "") + await app.set_administrator_title(configGet("destination_group"), self.id, "") + if (not await isAnAdmin(self.id)) and (chat.id == configGet("admin_group")): + await app.promote_chat_member(configGet("destination_group"), self.id, privileges=ChatPrivileges( + can_manage_chat=False + )) \ No newline at end of file diff --git a/config_example.json b/config_example.json index 785a6a4..cfa538b 100644 --- a/config_example.json +++ b/config_example.json @@ -4,8 +4,6 @@ "owner": 0, "bot_id": 0, "age_allowed": 0, - "birthdays_notify": true, - "birthdays_time": "09:00", "api": "http://example.com", "inline_preview_count": 7, "admin_group": 0, @@ -17,12 +15,28 @@ "api_hash": "", "bot_token": "" }, + "database": { + "user": null, + "password": null, + "host": "127.0.0.1", + "port": 27017, + "name": "holochecker" + }, "logging": { "size": 512, "location": "logs" }, + "scheduler": { + "birthdays": { + "time": 9, + "enabled": true + }, + "sponsorships": { + "time": 9, + "enabled": true + } + }, "locations": { - "data": "data", "cache": "cache", "locale": "locale" }, @@ -33,14 +47,16 @@ }, "commands_admin": { "reboot": "Restart the bot", - "message": "Send a message", + "message": "Send a message", + "label": "Set user's nickname", "warnings": "Check user's warnings", "application": "Check user's application", "applications": "Retrieve all applications as a JSON" }, "commands_group_admin": { "reboot": "Restart the bot", - "message": "Send a message", + "message": "Send a message", + "label": "Set user's nickname", "warnings": "Check user's warnings", "application": "Check user's application", "applications": "Retrieve all applications as a JSON" diff --git a/data/.gitkeep b/data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/sponsor_default.json b/data/sponsor_default.json deleted file mode 100644 index 1d13728..0000000 --- a/data/sponsor_default.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "filling": false, - "applied": false, - "approved": false, - "stage": 0, - "paid": null, - "expires": null, - "nickname": null -} \ No newline at end of file diff --git a/data/sponsors/.gitkeep b/data/sponsors/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/data/user_default.json b/data/user_default.json deleted file mode 100644 index d4e522c..0000000 --- a/data/user_default.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "stage": 0, - "reapply": false, - "link": null, - "sent": false, - "confirmed": false, - "approved": false, - "refused": false, - "telegram_id": null, - "telegram_name": null, - "telegram_phone": null, - "telegram_locale": null, - "application": { - "1": null, - "2": null, - "3": null, - "4": null, - "5": null, - "6": null, - "7": null, - "8": null, - "9": null, - "10": null - } -} \ No newline at end of file diff --git a/data/users/.gitkeep b/data/users/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/holochecker.py b/holochecker.py new file mode 100644 index 0000000..6cc31c9 --- /dev/null +++ b/holochecker.py @@ -0,0 +1,86 @@ +from os import getpid, makedirs +from modules.utils import * +from modules.inline import * +from app import app + +from modules.commands_register import commands_register +from pyrogram import idle + +pid = getpid() + +makedirs(f'{configGet("cache", "locations")}{sep}avatars', exist_ok=True) + +# Importing +from modules.commands.application import * +from modules.commands.applications import * +from modules.commands.label import * +from modules.commands.message import * +from modules.commands.reapply import * +from modules.commands.reboot import * +from modules.commands.rules import * +from modules.commands.sponsorship import * +from modules.commands.start import * +from modules.commands.warn import * +from modules.commands.warnings import * + +from modules.callbacks.nothing import * +from modules.callbacks.reapply import * +from modules.callbacks.rules import * +from modules.callbacks.sub import * +from modules.callbacks.sus import * + +from modules.handlers.confirmation import * +from modules.handlers.contact import * +from modules.handlers.group_join import * +from modules.handlers.welcome import * +from modules.handlers.everything import * + +from modules.scheduled import * + +if __name__ == "__main__": + + logWrite(f"Starting up with pid {pid}") + + # Yes, it should be in some kind of async main() function but I don't give a shit. + # I did compare performance, almost no difference and it's much more useful this way. Change my mind. + app.start() + + # if configGet("birthdays_notify"): + + # every().day.at(configGet("birthdays_time")).do(check_birthdays, app) + + # # Background tasks checker + # def background_task(): + # try: + # while True: + # try: + # run_pending() + # #print('Checked') + # time.sleep(1) + # except: + # pass + # except KeyboardInterrupt: + # print('\nShutting down') + # killProc(pid) + # t = Thread(target=background_task) + # t.start() + + try: + app.send_message(configGet("owner"), f"Starting up with pid `{pid}`") + except bad_request_400.PeerIdInvalid: + logWrite(f"Could not send startup message to bot owner. Perhaps user has not started the bot yet.") + + commands_register(app) + + scheduler.start() + + idle() + + try: + app.send_message(configGet("owner"), f"Shutting with pid `{pid}`") + except bad_request_400.PeerIdInvalid: + logWrite(f"Could not send shutdown message to bot owner. Perhaps user has not started the bot yet.") + + app.stop() + + killProc(pid) \ No newline at end of file diff --git a/logs/.gitkeep b/logs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/main.py b/main.py deleted file mode 100644 index 14da1f9..0000000 --- a/main.py +++ /dev/null @@ -1,95 +0,0 @@ -from threading import Thread -from time import time -from os import getpid, path -from modules.birthdays import check_birthdays -from modules.utils import * -from modules.inline import * -from schedule import run_pending, every -from app import app - -from modules.commands_register import commands_register -from pyrogram import idle - - -pid = getpid() - - -for entry in [f"{configGet('data', 'locations')}{sep}applications.json", f"{configGet('data', 'locations')}{sep}warnings.json"]: - mode = 'r' if path.exists(entry) else 'w' - with open(entry, mode) as f: - try: - f.write("{}") - except: - pass - -for entry in [f"{configGet('data', 'locations')}{sep}messages.json"]: - mode = 'r' if path.exists(entry) else 'w' - with open(entry, mode) as f: - try: - f.write("[]") - except: - pass - -# Importing -from modules.commands.application import * -from modules.commands.applications import * -from modules.commands.message import * -from modules.commands.reapply import * -from modules.commands.reboot import * -from modules.commands.rules import * -from modules.commands.sponsorship import * -from modules.commands.start import * -from modules.commands.warn import * -from modules.commands.warnings import * - -from modules.callbacks.nothing import * -from modules.callbacks.reapply import * -from modules.callbacks.rules import * -from modules.callbacks.sub import * -from modules.callbacks.sus import * - -from modules.handlers.confirmation import * -from modules.handlers.contact import * -from modules.handlers.group_join import * -from modules.handlers.welcome import * -from modules.handlers.everything import * - -if __name__ == "__main__": - - logWrite(f"Starting up with pid {pid}") - - # Yes, it should be in some kind of async main() function but I don't give a shit. - # I did compare performance, almost no difference and it's much more useful this way. Change my mind. - app.start() - - if configGet("birthdays_notify"): - - every().day.at(configGet("birthdays_time")).do(check_birthdays, app) - - # Background tasks checker - def background_task(): - try: - while True: - try: - run_pending() - #print('Checked') - time.sleep(1) - except: - pass - except KeyboardInterrupt: - print('\nShutting down') - killProc(pid) - t = Thread(target=background_task) - t.start() - - app.send_message(configGet("owner"), f"Starting up with pid `{pid}`") - - commands_register(app) - - idle() - - app.send_message(configGet("owner"), f"Shutting with pid `{pid}`") - - app.stop() - - killProc(pid) \ No newline at end of file diff --git a/modules/birthdays.py b/modules/birthdays.py deleted file mode 100644 index 04963d9..0000000 --- a/modules/birthdays.py +++ /dev/null @@ -1,18 +0,0 @@ -from datetime import datetime -from os import fsdecode, listdir, sep -from modules.utils import configGet, jsonLoad, locale -from dateutil.relativedelta import relativedelta - - -def check_birthdays(app): - for user_file in listdir(f"{configGet('data', 'locations')}{sep}users{sep}"): - filename = fsdecode(f"{configGet('data', 'locations')}{sep}users{sep}{user_file}") - if filename.endswith(".json"): - user = jsonLoad(filename) - if isinstance(user["application"]["2"], str): - try: - if ".".join([((user["application"]["2"]).split("."))[0], ((user["application"]["2"]).split("."))[1]]) == datetime.now().strftime("%d.%m"): - tg_user = app.get_users(int(user_file.replace(".json", ""))) - app.send_message( configGet("admin_group"), locale("birthday", "message").format(str(tg_user.first_name), str(tg_user.username), str(relativedelta(datetime.now(), datetime.strptime(user["application"]["2"], '%d.%m.%Y')).years)) ) - except AttributeError: - continue \ No newline at end of file diff --git a/modules/commands/applications.py b/modules/commands/applications.py index a15203c..4b7578c 100644 --- a/modules/commands/applications.py +++ b/modules/commands/applications.py @@ -1,8 +1,10 @@ -from os import sep +from os import sep, makedirs, remove +from uuid import uuid1 from app import app, isAnAdmin from pyrogram import filters from pyrogram.enums.chat_action import ChatAction -from modules.utils import configGet, should_quote +from modules.utils import configGet, should_quote, jsonSave +from modules.database import col_applications # Applications command ========================================================================================================= @app.on_message(~ filters.scheduled & filters.command(["applications"], prefixes=["/"])) @@ -10,5 +12,12 @@ async def cmd_applications(app, msg): if (await isAnAdmin(msg.from_user.id)) or (msg.chat.id == configGet("admin_group")): await app.send_chat_action(msg.chat.id, ChatAction.UPLOAD_DOCUMENT) - await msg.reply_document(document=f"{configGet('data', 'locations')}{sep}applications.json", quote=should_quote(msg)) + filename = uuid1() + output = [] + for entry in col_applications.find(): + output.append(entry) + makedirs("tmp", exist_ok=True) + jsonSave(output, f"tmp{sep}{filename}.json") + await msg.reply_document(document=f"tmp{sep}{filename}.json", file_name="applications", quote=should_quote(msg)) + remove(f"tmp{sep}{filename}.json") # ============================================================================================================================== \ No newline at end of file diff --git a/modules/commands/label.py b/modules/commands/label.py new file mode 100644 index 0000000..903edd6 --- /dev/null +++ b/modules/commands/label.py @@ -0,0 +1,33 @@ +from app import app, isAnAdmin +from pyrogram import filters +from pyrogram.types import ChatPrivileges +from modules.utils import should_quote, find_user, configGet +from classes.holo_user import HoloUser + +@app.on_message(~ filters.scheduled & filters.private & filters.command(["label"], prefixes=["/"])) +async def cmd_label(app, msg): + + if msg.chat.id == configGet("admin_group") or await isAnAdmin(msg.from_user.id): + + if len(msg.command) < 3: + await msg.reply_text("Invalid syntax:\n`/label USER LABEL`") + return + + target = await find_user(app, msg.command[1]) + + if target is not None: + + target = HoloUser(target) + + label = " ".join(msg.command[2:]) + + if label.lower() == "reset": + await target.reset_label(msg.chat) + await msg.reply_text(f"Resetting **{target.id}**'s label...", quote=should_quote(msg)) + + else: + await target.set_label(msg.chat, label) + await msg.reply_text(f"Setting **{target.id}**'s label to **{label}**...", quote=should_quote(msg)) + + else: + await msg.reply_text(f"User not found") \ No newline at end of file diff --git a/modules/commands/message.py b/modules/commands/message.py index 6ff544b..f05c33c 100644 --- a/modules/commands/message.py +++ b/modules/commands/message.py @@ -2,7 +2,9 @@ from os import sep from app import app, isAnAdmin from pyrogram import filters from pyrogram.errors import bad_request_400 +from classes.holo_user import HoloUser from modules.utils import jsonLoad, jsonSave, logWrite, locale, configGet, should_quote +from modules.database import col_messages # Message command ============================================================================================================== @app.on_message(~ filters.scheduled & filters.command(["message"], prefixes=["/"])) @@ -11,36 +13,25 @@ async def cmd_message(app, msg): if msg.chat.id == configGet("admin_group") or await isAnAdmin(msg.from_user.id): try: + try: - destination = await app.get_users(int(msg.command[1])) - if destination == [] or destination == None: - raise TypeError - except TypeError: - try: - destination = await app.get_users(msg.command[1]) - except bad_request_400.UsernameNotOccupied: - await msg.reply_text(locale("message_no_user", "message"), quote=should_quote(msg)) - logWrite(f"Admin {msg.from_user.id} tried to send message '{' '.join(msg.command[2:])}' to '{msg.command[1]}' but 'UsernameNotOccupied'") - return + destination = HoloUser(int(msg.command[1])) except ValueError: - try: - destination = await app.get_users(msg.command[1]) - except bad_request_400.UsernameNotOccupied: - await msg.reply_text(locale("message_no_user", "message"), quote=should_quote(msg)) - logWrite(f"Admin {msg.from_user.id} tried to send message '{' '.join(msg.command[2:])}' to '{msg.command[1]}' but 'UsernameNotOccupied'") - return + destination = HoloUser(msg.command[1]) + void = msg.command[2] message = " ".join(msg.command[2:]) - try: - new_message = await app.send_message(destination.id, message+locale("message_reply_notice", "message")) - await msg.reply_text(locale("message_sent", "message"), quote=should_quote(msg)) - logWrite(f"Admin {msg.from_user.id} sent message '{' '.join(msg.command[2:])}' to {destination.id}") - messages = jsonLoad(f"{configGet('data', 'locations')}{sep}messages.json") - messages.append({"origin": {"chat": msg.chat.id, "id": msg.id}, "destination": {"chat": new_message.chat.id, "id": new_message.id}}) - jsonSave(messages, f"{configGet('data', 'locations')}{sep}messages.json") - except bad_request_400.PeerIdInvalid: - await msg.reply_text(locale("message_no_user", "message"), quote=should_quote(msg)) - logWrite(f"Admin {msg.from_user.id} tried to send message '{' '.join(msg.command[2:])}' to {destination.id} but 'PeerIdInvalid'") + + await destination.message(msg, msg.command[2:]) + + # try: + # new_message = await app.send_message(destination.id, message+locale("message_reply_notice", "message")) + # await msg.reply_text(locale("message_sent", "message"), quote=should_quote(msg)) + # logWrite(f"Admin {msg.from_user.id} sent message '{' '.join(msg.command[2:])}' to {destination.id}") + # col_messages.insert_one({"origin": {"chat": msg.chat.id, "id": msg.id}, "destination": {"chat": new_message.chat.id, "id": new_message.id}}) + # except bad_request_400.PeerIdInvalid: + # await msg.reply_text(locale("message_no_user", "message"), quote=should_quote(msg)) + # logWrite(f"Admin {msg.from_user.id} tried to send message '{' '.join(msg.command[2:])}' to {destination.id} but 'PeerIdInvalid'") except IndexError: await msg.reply_text(locale("message_invalid_syntax", "message"), quote=should_quote(msg)) logWrite(f"Admin {msg.from_user.id} tried to send message but 'IndexError'") diff --git a/modules/commands/reboot.py b/modules/commands/reboot.py index fa434dd..1397f41 100644 --- a/modules/commands/reboot.py +++ b/modules/commands/reboot.py @@ -1,7 +1,9 @@ from app import app, isAnAdmin from os import getpid +from sys import exit from pyrogram import filters -from modules.utils import configGet, logWrite, killProc, should_quote +from modules.utils import configGet, logWrite, should_quote +from modules.scheduled import scheduler pid = getpid() @@ -12,5 +14,6 @@ async def cmd_kill(app, msg): if msg.chat.id == configGet("admin_group") or await isAnAdmin(msg.from_user.id): logWrite(f"Shutting down bot with pid {pid}") await msg.reply_text(f"Вимкнення бота з підом `{pid}`", quote=should_quote(msg)) - killProc(pid) + scheduler.shutdown() + exit() # ============================================================================================================================== \ No newline at end of file diff --git a/modules/commands/start.py b/modules/commands/start.py index 42c4cf2..da6eb29 100644 --- a/modules/commands/start.py +++ b/modules/commands/start.py @@ -2,24 +2,27 @@ from app import app from os import sep from pyrogram import filters from pyrogram.types import ReplyKeyboardMarkup -from modules.utils import jsonLoad, jsonSave, configGet, configSet, locale, logWrite +from modules.utils import locale, logWrite +from modules.database import col_users # Start command ================================================================================================================ @app.on_message(~ filters.scheduled & filters.private & filters.command(["start"], prefixes=["/"])) async def cmd_start(app, msg): - try: - user_stage = configGet("stage", file=str(msg.from_user.id)) - if user_stage != 0: - return - except FileNotFoundError: - jsonSave(jsonLoad(f"{configGet('data', 'locations')}{sep}user_default.json"), f"{configGet('data', 'locations')}{sep}users{sep}{msg.from_user.id}.json") - user_stage = configGet("stage", file=str(msg.from_user.id)) - configSet(["telegram_id"], str(msg.from_user.username), file=str(msg.from_user.id)) - configSet(["telegram_name"], f"{msg.from_user.first_name} {msg.from_user.last_name}", file=str(msg.from_user.id)) - configSet(["telegram_phone"], str(msg.from_user.phone_number), file=str(msg.from_user.id)) - configSet(["telegram_locale"], str(msg.from_user.language_code), file=str(msg.from_user.id)) + user = col_users.find_one({"user": msg.from_user.id}) - logWrite(f"User {msg.from_user.id} started bot interaction") - await msg.reply_text(locale("start", "message"), reply_markup=ReplyKeyboardMarkup(locale("welcome", "keyboard"), resize_keyboard=True)) + if user is None: + + col_users.insert_one({ + "user": msg.from_user.id, + "link": None, + "label": "", + "tg_name": msg.from_user.first_name, + "tg_phone": msg.from_user.phone_number, + "tg_locale": msg.from_user.language_code, + "tg_username": msg.from_user.username + }) + + logWrite(f"User {msg.from_user.id} started bot interaction") + await msg.reply_text(locale("start", "message"), reply_markup=ReplyKeyboardMarkup(locale("welcome", "keyboard"), resize_keyboard=True)) # ============================================================================================================================== \ No newline at end of file diff --git a/modules/commands_register.py b/modules/commands_register.py index 44b87a5..20a1419 100644 --- a/modules/commands_register.py +++ b/modules/commands_register.py @@ -1,3 +1,4 @@ +from modules.logging import logWrite from modules.utils import configGet from pyrogram.types import BotCommand, BotCommandScopeChat from pyrogram.errors import bad_request_400 @@ -24,16 +25,25 @@ def commands_register(app): except bad_request_400.PeerIdInvalid: pass - app.set_bot_commands(commands_admin_list, scope=BotCommandScopeChat(chat_id=configGet("owner"))) + try: + app.set_bot_commands(commands_admin_list, scope=BotCommandScopeChat(chat_id=configGet("owner"))) + except bad_request_400.PeerIdInvalid: + logWrite(f"Could not register commands for bot owner. Perhaps user has not started the bot yet.") # Registering admin group commands commands_group_admin_list = [] for command in configGet("commands_group_admin"): commands_group_admin_list.append(BotCommand(command, configGet("commands_group_admin")[command])) - app.set_bot_commands(commands_group_admin_list, scope=BotCommandScopeChat(chat_id=configGet("admin_group"))) + try: + app.set_bot_commands(commands_group_admin_list, scope=BotCommandScopeChat(chat_id=configGet("admin_group"))) + except bad_request_400.ChannelInvalid: + logWrite(f"Could not register commands for admin group. Bot is likely not in the group.") # Registering destination group commands commands_group_destination_list = [] for command in configGet("commands_group_destination"): commands_group_destination_list.append(BotCommand(command, configGet("commands_group_destination")[command])) - app.set_bot_commands(commands_group_destination_list, scope=BotCommandScopeChat(chat_id=configGet("destination_group"))) \ No newline at end of file + try: + app.set_bot_commands(commands_group_destination_list, scope=BotCommandScopeChat(chat_id=configGet("destination_group"))) + except bad_request_400.ChannelInvalid: + logWrite(f"Could not register commands for destination group. Bot is likely not in the group.") \ No newline at end of file diff --git a/modules/database.py b/modules/database.py new file mode 100644 index 0000000..ec90cbe --- /dev/null +++ b/modules/database.py @@ -0,0 +1,37 @@ +from pymongo import MongoClient +from ujson import loads + +with open("config.json", "r", encoding="utf-8") as f: + db_config = loads(f.read())["database"] + f.close() + +if db_config["user"] is not None and db_config["password"] is not None: + con_string = 'mongodb://{0}:{1}@{2}:{3}/{4}'.format( + db_config["user"], + db_config["password"], + db_config["host"], + db_config["port"], + db_config["name"] + ) +else: + con_string = 'mongodb://{0}:{1}/{2}'.format( + db_config["host"], + db_config["port"], + db_config["name"] + ) + +db_client = MongoClient(con_string) +db = db_client.get_database(name=db_config["name"]) + +collections = db.list_collection_names() + +for collection in ["users", "context", "messages", "warnings", "applications", "sponsorships"]: + if not collection in collections: + db.create_collection(collection) + +col_users = db.get_collection("users") +col_context = db.get_collection("context") +col_messages = db.get_collection("messages") +col_warnings = db.get_collection("warnings") +col_applications = db.get_collection("applications") +col_sponsorships = db.get_collection("sponsorships") \ No newline at end of file diff --git a/modules/handlers/everything.py b/modules/handlers/everything.py index 5032ad9..e4495a4 100644 --- a/modules/handlers/everything.py +++ b/modules/handlers/everything.py @@ -5,95 +5,93 @@ import asyncio from pyrogram import filters from pyrogram.types import ForceReply, ReplyKeyboardMarkup, Message from modules.utils import configGet, configSet, jsonLoad, jsonSave, locale, logWrite, should_quote +from modules.database import col_messages -async def message_involved(msg: Message): - messages = jsonLoad(f"{configGet('data', 'locations')}{sep}messages.json") - for message in messages: - if (message["destination"]["id"] == msg.reply_to_message.id) and (message["destination"]["chat"] == msg.reply_to_message.chat.id): - return True +async def message_involved(msg: Message) -> bool: + message = col_messages.find_one({"destination.id": msg.reply_to_message.id, "destination.chat": msg.reply_to_message.chat.id}) + if message is not None: + return True return False -async def message_context(msg: Message): - messages = jsonLoad(f"{configGet('data', 'locations')}{sep}messages.json") - for message in messages: - if (message["destination"]["id"] == msg.reply_to_message.id) and (message["destination"]["chat"] == msg.reply_to_message.chat.id): - return message["origin"]["chat"], message["origin"]["id"] +async def message_context(msg: Message) -> tuple: + message = col_messages.find_one({"destination.id": msg.reply_to_message.id, "destination.chat": msg.reply_to_message.chat.id}) + if message is not None: + return message["origin"]["chat"], message["origin"]["id"] + return 0, 0 # Any other input ============================================================================================================== -@app.on_message(~ filters.scheduled & filters.private) -async def any_stage(app, msg): +# @app.on_message(~ filters.scheduled & filters.private) +# async def any_stage(app, msg): - if msg.via_bot is None: +# if msg.via_bot is None: - if (msg.reply_to_message != None) and (await message_involved(msg)): - context = await message_context(msg) - if msg.chat.id == configGet("admin_group") or await isAnAdmin(msg.from_user.id): - new_message = await (await app.get_messages(context[0], context[1])).reply_text(msg.text+locale("message_reply_notice", "message"), quote=True) - else: - new_message = await (await app.get_messages(context[0], context[1])).reply_text(locale("message_from", "message").format(msg.from_user.first_name, msg.from_user.id)+msg.text+locale("message_reply_notice", "message"), quote=True) - await msg.reply_text(locale("message_sent", "message"), quote=should_quote(msg)) - messages = jsonLoad(f"{configGet('data', 'locations')}{sep}messages.json") - messages.append({"origin": {"chat": msg.chat.id, "id": msg.id}, "destination": {"chat": new_message.chat.id, "id": new_message.id}}) - jsonSave(messages, f"{configGet('data', 'locations')}{sep}messages.json") - return +# if (msg.reply_to_message != None) and (await message_involved(msg)): +# context = await message_context(msg) +# if msg.chat.id == configGet("admin_group") or await isAnAdmin(msg.from_user.id): +# new_message = await (await app.get_messages(context[0], context[1])).reply_text(msg.text+locale("message_reply_notice", "message"), quote=True) +# else: +# new_message = await (await app.get_messages(context[0], context[1])).reply_text(locale("message_from", "message").format(msg.from_user.first_name, msg.from_user.id)+msg.text+locale("message_reply_notice", "message"), quote=True) +# await msg.reply_text(locale("message_sent", "message"), quote=should_quote(msg)) +# col_messages.insert_one({"origin": {"chat": msg.chat.id, "id": msg.id}, "destination": {"chat": new_message.chat.id, "id": new_message.id}}) +# return - user_stage = configGet("stage", file=str(msg.from_user.id)) +# user_stage = configGet("stage", file=str(msg.from_user.id)) - if user_stage == 1: - await msg.reply_text(locale(f"question{user_stage+1}", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage+1}", "force_reply")))) - logWrite(f"User {msg.from_user.id} completed stage {user_stage} of application") - configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) - configSet(["stage"], user_stage+1, file=str(msg.from_user.id)) +# if user_stage == 1: +# await msg.reply_text(locale(f"question{user_stage+1}", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage+1}", "force_reply")))) +# logWrite(f"User {msg.from_user.id} completed stage {user_stage} of application") +# configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) +# configSet(["stage"], user_stage+1, file=str(msg.from_user.id)) - elif user_stage == 2: +# elif user_stage == 2: - try: +# try: - configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) +# configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) - input_dt = datetime.strptime(msg.text, "%d.%m.%Y") +# input_dt = datetime.strptime(msg.text, "%d.%m.%Y") - if datetime.now() <= input_dt: - logWrite(f"User {msg.from_user.id} failed stage {user_stage} due to joking") - await msg.reply_text(locale("question2_joke", "message"), reply_markup=ForceReply(placeholder=str(locale("question2", "force_reply")))) +# if datetime.now() <= input_dt: +# logWrite(f"User {msg.from_user.id} failed stage {user_stage} due to joking") +# await msg.reply_text(locale("question2_joke", "message"), reply_markup=ForceReply(placeholder=str(locale("question2", "force_reply")))) - elif ((datetime.now() - input_dt).days) < ((datetime.now() - datetime.now().replace(year=datetime.now().year - configGet("age_allowed"))).days): - logWrite(f"User {msg.from_user.id} failed stage {user_stage} due to being underage") - await msg.reply_text(locale("question2_underage", "message").format(str(configGet("age_allowed"))), reply_markup=ForceReply(placeholder=str(locale("question2", "force_reply")))) +# elif ((datetime.now() - input_dt).days) < ((datetime.now() - datetime.now().replace(year=datetime.now().year - configGet("age_allowed"))).days): +# logWrite(f"User {msg.from_user.id} failed stage {user_stage} due to being underage") +# await msg.reply_text(locale("question2_underage", "message").format(str(configGet("age_allowed"))), reply_markup=ForceReply(placeholder=str(locale("question2", "force_reply")))) - else: - logWrite(f"User {msg.from_user.id} completed stage {user_stage} of application") - await msg.reply_text(locale(f"question{user_stage+1}", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage+1}", "force_reply")))) - configSet(["stage"], user_stage+1, file=str(msg.from_user.id)) +# else: +# logWrite(f"User {msg.from_user.id} completed stage {user_stage} of application") +# await msg.reply_text(locale(f"question{user_stage+1}", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage+1}", "force_reply")))) +# configSet(["stage"], user_stage+1, file=str(msg.from_user.id)) - except ValueError: - logWrite(f"User {msg.from_user.id} failed stage {user_stage} due to sending invalid date format") - await msg.reply_text(locale(f"question2_invalid", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage}", "force_reply")))) +# except ValueError: +# logWrite(f"User {msg.from_user.id} failed stage {user_stage} due to sending invalid date format") +# await msg.reply_text(locale(f"question2_invalid", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage}", "force_reply")))) - else: - if user_stage <= 9: - logWrite(f"User {msg.from_user.id} completed stage {user_stage} of application") - await msg.reply_text(locale(f"question{user_stage+1}", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage+1}", "force_reply")))) - configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) - configSet(["stage"], user_stage+1, file=str(msg.from_user.id)) - else: - if not configGet("sent", file=str(msg.from_user.id)): - if not configGet("confirmed", file=str(msg.from_user.id)): - configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) - application_content = [] - i = 1 - for question in configGet("application", file=str(msg.from_user.id)): - application_content.append(f"{locale('question'+str(i), 'message', 'question_titles')} {configGet('application', file=str(msg.from_user.id))[question]}") - i += 1 - await msg.reply_text(locale("confirm", "message").format("\n".join(application_content)), reply_markup=ReplyKeyboardMarkup(locale("confirm", "keyboard"), resize_keyboard=True)) - #configSet("sent", True, file=str(msg.from_user.id)) - #configSet("application_date", int(time()), file=str(msg.from_user.id)) - else: - if not configGet("approved", file=str(msg.from_user.id)) and not configGet("refused", file=str(msg.from_user.id)): - await msg.reply_text(locale("already_sent", "message")) - else: - if not configGet("approved", file=str(msg.from_user.id)) and not configGet("refused", file=str(msg.from_user.id)): - await msg.reply_text(locale("already_sent", "message")) +# else: +# if user_stage <= 9: +# logWrite(f"User {msg.from_user.id} completed stage {user_stage} of application") +# await msg.reply_text(locale(f"question{user_stage+1}", "message"), reply_markup=ForceReply(placeholder=str(locale(f"question{user_stage+1}", "force_reply")))) +# configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) +# configSet(["stage"], user_stage+1, file=str(msg.from_user.id)) +# else: +# if not configGet("sent", file=str(msg.from_user.id)): +# if not configGet("confirmed", file=str(msg.from_user.id)): +# configSet(["application", str(user_stage)], str(msg.text), file=str(msg.from_user.id)) +# application_content = [] +# i = 1 +# for question in configGet("application", file=str(msg.from_user.id)): +# application_content.append(f"{locale('question'+str(i), 'message', 'question_titles')} {configGet('application', file=str(msg.from_user.id))[question]}") +# i += 1 +# await msg.reply_text(locale("confirm", "message").format("\n".join(application_content)), reply_markup=ReplyKeyboardMarkup(locale("confirm", "keyboard"), resize_keyboard=True)) +# #configSet("sent", True, file=str(msg.from_user.id)) +# #configSet("application_date", int(time()), file=str(msg.from_user.id)) +# else: +# if not configGet("approved", file=str(msg.from_user.id)) and not configGet("refused", file=str(msg.from_user.id)): +# await msg.reply_text(locale("already_sent", "message")) +# else: +# if not configGet("approved", file=str(msg.from_user.id)) and not configGet("refused", file=str(msg.from_user.id)): +# await msg.reply_text(locale("already_sent", "message")) @app.on_message(~ filters.scheduled & filters.group) async def message_in_group(app, msg): diff --git a/modules/handlers/welcome.py b/modules/handlers/welcome.py index 6c5f5fc..a484ac1 100644 --- a/modules/handlers/welcome.py +++ b/modules/handlers/welcome.py @@ -1,6 +1,7 @@ from app import app from pyrogram import filters from pyrogram.types import ForceReply, ReplyKeyboardMarkup +from classes.holo_user import HoloUser from modules.utils import configGet, configSet, locale, logWrite # Welcome check ================================================================================================================ @@ -14,6 +15,8 @@ async def welcome_pass(app, msg, once_again: bool = True) -> None: * once_again (bool, optional): Set to False if it's the first time as user applies. Defaults to True. """ + holo_user = HoloUser(msg.from_user) + if not once_again: await msg.reply_text(locale("privacy_notice", "message")) diff --git a/modules/scheduled.py b/modules/scheduled.py new file mode 100644 index 0000000..19577ed --- /dev/null +++ b/modules/scheduled.py @@ -0,0 +1,35 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from datetime import datetime +from os import fsdecode, listdir, sep +from app import app +from modules.utils import configGet, jsonLoad, locale, logWrite +from dateutil.relativedelta import relativedelta +from modules.database import col_applications + +scheduler = AsyncIOScheduler() + +# for user_file in listdir(f"{configGet('data', 'locations')}{sep}users{sep}"): +# filename = fsdecode(f"{configGet('data', 'locations')}{sep}users{sep}{user_file}") +# if filename.endswith(".json"): +# user = jsonLoad(filename) +# if isinstance(user["application"]["2"], str): +# try: +# if ".".join([((user["application"]["2"]).split("."))[0], ((user["application"]["2"]).split("."))[1]]) == datetime.now().strftime("%d.%m"): +# tg_user = await app.get_users(int(user_file.replace(".json", ""))) +# await app.send_message( configGet("admin_group"), locale("birthday", "message").format(str(tg_user.first_name), str(tg_user.username), str(relativedelta(datetime.now(), datetime.strptime(user["application"]["2"], '%d.%m.%Y')).years)) ) +# except AttributeError: +# continue + +if configGet("enabled", "scheduler", "birthdays"): + @scheduler.scheduled_job(trigger="cron", hour=configGet("time", "scheduler", "birthdays")) + async def check_birthdays(): + for entry in col_applications.find({"2": datetime.now().strftime("%d.%m.%Y")}): + tg_user = await app.get_users(entry["user"]) + await app.send_message( configGet("admin_group"), locale("birthday", "message").format(str(tg_user.first_name), str(tg_user.username), str(relativedelta(datetime.now(), datetime.strptime(entry["2"], '%d.%m.%Y')).years)) ) # type: ignore + logWrite(f"Notified admins about {entry['user']}'s birthday") + logWrite("Birthdays check performed") + +if configGet("enabled", "scheduler", "sponsorships"): + @scheduler.scheduled_job(trigger="cron", hour=configGet("time", "scheduler", "sponsorships")) + async def check_sponsors(): + logWrite("Sponsorships check performed") \ No newline at end of file diff --git a/modules/utils.py b/modules/utils.py index cebdded..81c1165 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -1,5 +1,8 @@ from typing import Any, Union from pyrogram.enums.chat_type import ChatType +from pyrogram.types import User +from pyrogram.client import Client +from pyrogram.errors import bad_request_400 from ujson import JSONDecodeError as JSONDecodeError from ujson import loads, dumps @@ -49,11 +52,11 @@ def nested_set(dic, keys, value, create_missing=True): def configSet(keys: list, value: Any, file: str = "config", create_missing=True): """Set config's value to provided one - Args: - * keys (list): List of keys from the highest one to target - * value (Any): Needed value - * file (str, optional): File (if not config). Defaults to "config". - * create_missing (bool, optional): Create missing items on the way. Defaults to True. + ### Args: + * keys (`list`): List of keys from the highest one to target + * value (`Any`): Needed value + * file (`str`, optional): File (if not config). Defaults to "config". + * create_missing (`bool`, optional): Create missing items on the way. Defaults to True. """ if file == "config": filepath = "" @@ -74,11 +77,11 @@ def configSet(keys: list, value: Any, file: str = "config", create_missing=True) def configGet(key: str, *args: str, file: str = "config"): """Get value of the config key - Args: - * key (str): The last key of the keys path. - * *args (str): Path to key like: dict[args][key]. - * file (str): User ID to load. Loads config if not provided. Defaults to "config". - Returns: + ### Args: + * key (`str`): The last key of the keys path. + * *args (`str`): Path to key like: dict[args][key]. + * file (`str`): User ID to load. Loads config if not provided. Defaults to "config". + ### Returns: * any: Value of provided key """ if file == "config": @@ -99,16 +102,19 @@ def configGet(key: str, *args: str, file: str = "config"): this_key = this_key[dict_key] return this_key[key] -def locale(key: str, *args: str, locale=configGet("locale")) -> Union[str, list, dict]: +def locale(key: str, *args: str, locale: Union[str, User] = configGet("locale")) -> Any: """Get value of locale string - Args: - * key (str): The last key of the locale's keys path. - * *args (list): Path to key like: dict[args][key]. - * locale (str): Locale to looked up in. Defaults to config's locale value. - Returns: - * any: Value of provided locale key + ### Args: + * key (`str`): The last key of the locale's keys path. + * *args (`list`): Path to key like: dict[args][key]. + * locale (`Union[str, User]`): Locale to looked up in. Provide User to get his `.language_code`. Defaults to config's locale value. + ### Returns: + * any: Value of provided locale key. In normal case must be `str`, `dict` or `list`. """ - if (locale == None): + if isinstance(locale, User): + locale = locale.language_code + + if locale is None: locale = configGet("locale") try: @@ -144,4 +150,20 @@ def killProc(pid): p.kill() def should_quote(msg): - return True if msg.chat.type is not ChatType.PRIVATE else False \ No newline at end of file + return True if msg.chat.type is not ChatType.PRIVATE else False + +async def find_user(app: Client, query: Union[str, int]): + try: + result = await app.get_users(int(query)) + if result == [] or result == None: + raise TypeError + except (TypeError, ValueError): + try: + result = await app.get_users(query) + except bad_request_400.UsernameNotOccupied: + return None + except bad_request_400.UsernameInvalid: + return None + except bad_request_400.PeerIdInvalid: + return None + return result \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b610113..1409df8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,9 @@ -pyrogram>=2.0.59 -tgcrypto>=1.2.4 -ujson>=5.5.0 -psutil>=5.9.2 -schedule -fastapi -uvicorn[standard] \ No newline at end of file +APScheduler==3.9.1.post1 +fastapi==0.88.0 +psutil==5.9.4 +pymongo==4.3.3 +Pyrogram==2.0.69 +tgcrypto==1.2.5 +python_dateutil==2.8.2 +starlette==0.22.0 +ujson==5.6.0 \ No newline at end of file diff --git a/validation/applications.json b/validation/applications.json new file mode 100644 index 0000000..026bd47 --- /dev/null +++ b/validation/applications.json @@ -0,0 +1,6 @@ +{ + "$jsonSchema": { + "required": [], + "properties": {} + } +} \ No newline at end of file diff --git a/validation/context.json b/validation/context.json new file mode 100644 index 0000000..026bd47 --- /dev/null +++ b/validation/context.json @@ -0,0 +1,6 @@ +{ + "$jsonSchema": { + "required": [], + "properties": {} + } +} \ No newline at end of file diff --git a/validation/messages.json b/validation/messages.json new file mode 100644 index 0000000..6148354 --- /dev/null +++ b/validation/messages.json @@ -0,0 +1,36 @@ +{ + "$jsonSchema": { + "required": [ + "origin", + "origin.chat", + "origin.id", + "destination", + "destination.chat", + "destination.id" + ], + "properties": { + "origin": { + "bsonType": "object" + }, + "origin.chat": { + "bsonType": ["int", "long"], + "description": "Telegram ID of message's origin chat" + }, + "origin.id": { + "bsonType": ["int", "long"], + "description": "ID of message in origin chat" + }, + "destination": { + "bsonType": "object" + }, + "destination.chat": { + "bsonType": ["int", "long"], + "description": "Telegram ID of message's destination chat" + }, + "destination.id": { + "bsonType": ["int", "long"], + "description": "ID of message in destination chat" + } + } + } +} \ No newline at end of file diff --git a/validation/sponsorships.json b/validation/sponsorships.json new file mode 100644 index 0000000..026bd47 --- /dev/null +++ b/validation/sponsorships.json @@ -0,0 +1,6 @@ +{ + "$jsonSchema": { + "required": [], + "properties": {} + } +} \ No newline at end of file diff --git a/validation/users.json b/validation/users.json new file mode 100644 index 0000000..7cb76af --- /dev/null +++ b/validation/users.json @@ -0,0 +1,43 @@ +{ + "$jsonSchema": { + "required": [ + "user", + "link", + "label", + "tg_name", + "tg_phone", + "tg_locale", + "tg_username" + ], + "properties": { + "user": { + "bsonType": ["int", "long"], + "description": "Telegram ID of user" + }, + "link": { + "bsonType": ["string", "null"], + "description": "Invite link to destination group" + }, + "label": { + "bsonType": "string", + "description": "Label given by admins" + }, + "tg_name": { + "bsonType": "string", + "description": "Telegram first name" + }, + "tg_phone": { + "bsonType": ["string", "null"], + "description": "Telegram phone number" + }, + "tg_locale": { + "bsonType": ["string", "null"], + "description": "Telegram locale" + }, + "tg_username": { + "bsonType": ["string", "null"], + "description": "Telegram username" + } + } + } +} \ No newline at end of file diff --git a/validation/warnings.json b/validation/warnings.json new file mode 100644 index 0000000..026bd47 --- /dev/null +++ b/validation/warnings.json @@ -0,0 +1,6 @@ +{ + "$jsonSchema": { + "required": [], + "properties": {} + } +} \ No newline at end of file