""" main.py - bot for installin modules by buttons in Telegram channels via Limoka """ import os import json import logging import time import asyncio import hashlib import re from datetime import datetime from typing import Optional, Dict, List import aiohttp from aiogram import Bot, Dispatcher, Router from aiogram.filters import Command, BaseFilter from aiogram.types import ( Message, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, ) from aiogram.exceptions import TelegramForbiddenError, TelegramAPIError from aiogram.utils.formatting import ( Text, Bold, Code, Italic, as_section, as_list, HashTag, ) from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ed25519 from dotenv import load_dotenv # === CONFIGURATION === load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN", "123456789:AA11CC22DD33EE44FF55GG66HH77II88JJ99") ADMIN_IDS = set(map(int, os.getenv("ADMIN_IDS", "123").split(","))) WHITELIST_FILE = "whitelist.json" PRIVATE_KEY_FILE = os.getenv("PRIVATE_KEY_FILE", "key.pem") MODULES_JSON_URL = "https://raw.githubusercontent.com/MuRuLOSE/limoka/main/modules.json" MODULES_BASE_URL = MODULES_JSON_URL.replace("modules.json", "") UPDATE_INTERVAL_MINUTES = 30 # === STRINGS (only plain text β€” formatting done via aiogram.utils.formatting) === STRINGS = { "en": { # Startup "logging_initialized": "βœ… Logging initialized with UTF-8 support", "starting_bot": "πŸš€ Starting Limoka Install Bot...", "security_manager_initialized": "βœ… Security manager initialized", "security_manager_failed": "❌ Failed to initialize security manager: {}", "security_critical_error": "Bot will not be able to sign modules - shutting down", "modules_loaded": "βœ… Loaded {} modules from repository", "modules_auto_updater": "πŸ”„ Starting modules.json auto-updater (every {} minutes)", "auto_update_failed": "❌ Auto-update failed: {}", "startup_notification_title": "Limoka Install Bot started successfully!", "startup_notification_body": [ "β€’ Enhanced whitelist: βœ… Active (channelβ†’repo mapping)", "β€’ Time validation: ❌ Removed", "β€’ Module signing: βœ… Ed25519", "", "πŸ”§ Admin commands:", "/addrepo β€” Add repo to channel", "/rmrepo β€” Remove repo from channel", "/whitelist β€” Show all whitelisted channels", ], "bot_running": "βœ… Bot is now running and listening for updates", "shutting_down": "πŸ›‘ Shutting down Limoka Install Bot...", # Whitelist "whitelist_file_created": "Created new whitelist file: {}", "whitelist_loaded": "Loaded whitelist with {} channels", "whitelist_load_failed": "❌ Error loading whitelist: {}", "whitelist_saved": "βœ… Saved whitelist with {} channels", "whitelist_save_failed": "❌ Error saving whitelist: {}", "channel_added": "βœ… Channel {} added to whitelist.", "channel_already_exists": "ℹ️ Channel {} already in whitelist.", "channel_removed": "βœ… Channel {} removed from whitelist.", "channel_not_found": "❌ Channel {} not found in whitelist.", "channel_normalized": "Normalized channel ID: {} β†’ {}", "whitelist_empty": "πŸ“‹ Whitelist is empty.", "whitelist_title": "πŸ“‹ Whitelisted channels:", "repo_added": "βœ… Repository {} added to channel {} whitelist.", "repo_already_exists": "ℹ️ Repository {} already in whitelist for channel {}.", "repo_removed": "βœ… Repository {} removed from channel {} whitelist.", "repo_not_found": "❌ Repository {} not found in whitelist for channel {}.", "channel_not_in_whitelist": "❌ Channel {} not found in whitelist.", # Commands "unauthorized_access": "❌ You are not authorized to use this bot.", "start_command_title": "Limoka Install Bot", "start_command_body": [ "Commands:", "/whitelist β€” Show whitelisted channels", "/addchannel β€” Add channel to whitelist", "/rmchannel β€” Remove channel from whitelist", "/reload β€” Reload modules.json", ], "addchannel_usage": "❌ Usage: /addchannel ", "rmchannel_usage": "❌ Usage: /rmchannel ", "addrepo_usage": "❌ Usage: /addrepo \nExample: /addrepo -1003377102183 MuRuLOSE/limoka-modules", "rmrepo_usage": "❌ Usage: /rmrepo \nExample: /rmrepo -1003377102183 MuRuLOSE/limoka-modules", "reload_command": "πŸ”„ Reloading modules.json...", "modules_reloaded": "βœ… Loaded {} modules.", "envforceupdate_success": "βœ… Environment variables reloaded.", "current_admin_ids": "Current ADMIN_IDS:", # Callbacks "callback_format_invalid": "❌ Invalid callback data format", "service_unavailable": "❌ Service unavailable β€” modules database not loaded", "module_not_in_database": "❌ Module not found in database", "hash_collision": "⚠️ Hash collision detected for paths: {}", "ambiguous_module": "❌ Ambiguous module match β€” please try again", "signature_verification_failed": "❌ Signature verification failed! Installation aborted.", "signature_spoof_attempt": "❌ Signature spoof attempt detected for module: {}", "message_sent": "βœ… Message sent! Check your private messages.", "cannot_send_messages": "❌ I can't send you messages. Please start a chat with me first!", "install_success": "βœ… Module installed successfully!", "install_failed": "❌ Installation failed:\n{}", # Buttons "install_button_text": "πŸ‹ Install via Limoka", "install_reply_text": "πŸ‹ Install via Limoka:", # Filters "channel_whitelisted": "βœ… Channel {} is whitelisted (allowed repos: {})", "channel_not_whitelisted": "❌ Channel {} is NOT whitelisted", "limoka_tag_found": "βœ… Found limoka tag: {}", "limoka_tag_processing": "🎯 Processing limoka tag: {}", } } # === LOGGING === # Configure logging: write DEBUG+ to file, but keep console output concise (INFO+). logger = logging.getLogger("LimokaBot") logger.setLevel(logging.DEBUG) # File handler: store all logs including DEBUG for diagnostics file_handler = logging.FileHandler("limoka_bot.log", encoding="utf-8") file_handler.setLevel(logging.DEBUG) file_formatter = logging.Formatter( "%(asctime)s | %(name)s | %(levelname)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) file_handler.setFormatter(file_formatter) # Console/stream handler: only INFO+ to avoid noisy debug output in production stream_handler = logging.StreamHandler() stream_handler.setLevel(logging.INFO) stream_formatter = logging.Formatter( "%(asctime)s | %(levelname)s | %(message)s", datefmt="%Y-%m-%d %H:%M:%S" ) stream_handler.setFormatter(stream_formatter) # Attach handlers (avoid duplicate handlers if reloading) if not logger.handlers: logger.addHandler(file_handler) logger.addHandler(stream_handler) logger.info(STRINGS["en"]["logging_initialized"]) # === INIT === bot = Bot(token=BOT_TOKEN) dp = Dispatcher() router = Router() _cached_modules: Optional[Dict] = None _modules_last_update: float = 0.0 _security_manager = None # === SECURITY MANAGER === class SecurityManager: def __init__(self, private_key_path: str): if not os.path.exists(private_key_path): raise FileNotFoundError(f"Private key not found: {private_key_path}") try: with open(private_key_path, "rb") as f: self.private_key = serialization.load_pem_private_key( f.read(), password=None ) if not isinstance(self.private_key, ed25519.Ed25519PrivateKey): raise ValueError("Only Ed25519 keys are supported") logger.info("βœ… Ed25519 private key loaded successfully") except Exception as e: logger.error(f"❌ Failed to load private key: {e}") raise def sign(self, data: str) -> str: try: signature = self.private_key.sign(data.encode()) return signature.hex() except Exception as e: logger.error(f"❌ Signing failed: {e}") raise # === WHITELIST === def load_whitelist() -> dict: try: if not os.path.exists(WHITELIST_FILE): default = {"channels": {}, "last_updated": datetime.now().isoformat()} with open(WHITELIST_FILE, "w", encoding="utf-8") as f: json.dump(default, f, indent=2, ensure_ascii=False) logger.info(f"Created new whitelist file: {WHITELIST_FILE}") return default with open(WHITELIST_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception as e: logger.error(f"❌ Error loading whitelist: {e}") return {"channels": {}} def save_whitelist(data: dict): data["last_updated"] = datetime.now().isoformat() try: with open(WHITELIST_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) logger.info(f"βœ… Saved whitelist with {len(data['channels'])} channels") except Exception as e: logger.error(f"❌ Error saving whitelist: {e}") # === MODULES === async def fetch_modules_json() -> Optional[Dict]: global _cached_modules, _modules_last_update try: async with aiohttp.ClientSession() as session: async with session.get(MODULES_JSON_URL, timeout=15) as resp: if resp.status != 200: logger.error(f"❌ Failed to fetch modules.json: HTTP {resp.status}") return None text = await resp.text() data = json.loads(text) _cached_modules = data _modules_last_update = time.time() logger.info(STRINGS["en"]["modules_loaded"].format(len(data))) return data except json.JSONDecodeError as e: logger.error(f"❌ Invalid JSON in modules.json: {e}") return None except Exception as e: logger.error(f"❌ Fetch failed: {e}") return None async def get_modules() -> Dict: global _cached_modules if _cached_modules is None: await fetch_modules_json() return _cached_modules or {} async def modules_updater(): logger.info(STRINGS["en"]["modules_auto_updater"].format(UPDATE_INTERVAL_MINUTES)) while True: try: await fetch_modules_json() except Exception as e: logger.error(STRINGS["en"]["auto_update_failed"].format(e)) await asyncio.sleep(UPDATE_INTERVAL_MINUTES * 60) # === FILTERS === class WhitelistChannelFilter(BaseFilter): async def __call__(self, message: Message) -> bool: if not message.chat: return False whitelist = load_whitelist() chat_id = str(message.chat.id) return chat_id in whitelist["channels"] class LimokaTagFilter(BaseFilter): async def __call__(self, message: Message) -> Optional[Dict[str, str]]: text = (message.text or message.caption or "").strip() if not text: return False pattern = r"#limoka:([a-zA-Z0-9._-]+)/([a-zA-Z0-9._-]+)/([a-zA-Z0-9_/.%-]+\.(?:py|pyc|pyo|pyd))" match = re.search(pattern, text) if not match: return False username, repo, module_path = match.groups() full_path = f"{username}/{repo}/{module_path}" return { "username": username, "repo": repo, "module_path": module_path, "full_path": full_path, } # === UTILS === def normalize_channel_id(cid: str) -> str: if cid.startswith("-100"): return cid if cid.startswith("-"): return "-100" + cid[1:] if cid.isdigit(): return "-100" + cid return cid # === COMMAND HANDLERS === @router.message(Command("start")) async def cmd_start(message: Message): if message.from_user.id not in ADMIN_IDS: await message.answer(STRINGS["en"]["unauthorized_access"]) return content = as_section( Bold(STRINGS["en"]["start_command_title"]), "", *STRINGS["en"]["start_command_body"], "", Bold("Current chat ID:"), Code(str(message.chat.id)), ) await message.answer(**content.as_kwargs()) # === COMMAND HANDLERS === @router.message(Command("envforceupdate")) async def cmd_envforceupdate(message: Message): if message.from_user.id not in ADMIN_IDS: await message.answer(STRINGS["en"]["unauthorized_access"]) return load_dotenv() content = as_section( Bold(STRINGS["en"]["envforceupdate_success"]), "", Bold(STRINGS["en"]["current_admin_ids"]), Code(str(ADMIN_IDS)), ) await message.answer(**content.as_kwargs()) @router.message(Command("whitelist")) async def cmd_whitelist(message: Message): if message.from_user.id not in ADMIN_IDS: return whitelist = load_whitelist() channels = whitelist["channels"] if not channels: await message.answer(STRINGS["en"]["whitelist_empty"]) return items = [Code(cid) for cid in sorted(channels.keys())] content = as_section(Bold(STRINGS["en"]["whitelist_title"]), *items) await message.answer(**content.as_kwargs()) @router.message(Command("addchannel")) async def cmd_addchannel(message: Message): if message.from_user.id not in ADMIN_IDS: return args = message.text.split(maxsplit=1) if len(args) < 2: await message.answer(STRINGS["en"]["addchannel_usage"]) return raw_id = args[1].strip() norm_id = normalize_channel_id(raw_id) logger.info(f"Normalized: {raw_id} β†’ {norm_id}") whitelist = load_whitelist() if norm_id not in whitelist["channels"]: whitelist["channels"][norm_id] = { "allowed_repos": [], "added_date": datetime.now().isoformat(), } save_whitelist(whitelist) await message.answer(STRINGS["en"]["channel_added"].format(norm_id)) else: await message.answer(STRINGS["en"]["channel_already_exists"].format(norm_id)) @router.message(Command("rmchannel")) async def cmd_rmchannel(message: Message): if message.from_user.id not in ADMIN_IDS: return args = message.text.split(maxsplit=1) if len(args) < 2: await message.answer(STRINGS["en"]["rmchannel_usage"]) return raw_id = args[1].strip() norm_id = normalize_channel_id(raw_id) whitelist = load_whitelist() if norm_id in whitelist["channels"]: del whitelist["channels"][norm_id] save_whitelist(whitelist) await message.answer(STRINGS["en"]["channel_removed"].format(norm_id)) else: await message.answer(STRINGS["en"]["channel_not_found"].format(norm_id)) @router.message(Command("addrepo")) async def cmd_addrepo(message: Message): if message.from_user.id not in ADMIN_IDS: return args = message.text.split(maxsplit=2) if len(args) < 3: content = as_section(Bold("❌ Usage:"), Code("/addrepo ")) await message.answer(**content.as_kwargs()) return raw_cid, repo = args[1], args[2] cid = normalize_channel_id(raw_cid) whitelist = load_whitelist() if cid not in whitelist["channels"]: await message.answer(STRINGS["en"]["channel_not_in_whitelist"].format(cid)) return if repo not in whitelist["channels"][cid]["allowed_repos"]: whitelist["channels"][cid]["allowed_repos"].append(repo) save_whitelist(whitelist) await message.answer(STRINGS["en"]["repo_added"].format(repo, cid)) else: await message.answer(STRINGS["en"]["repo_already_exists"].format(repo, cid)) @router.message(Command("rmrepo")) async def cmd_rmrepo(message: Message): if message.from_user.id not in ADMIN_IDS: return args = message.text.split(maxsplit=2) if len(args) < 3: content = as_section(Bold("❌ Usage:"), Code("/rmrepo ")) await message.answer(**content.as_kwargs()) return raw_cid, repo = args[1], args[2] cid = normalize_channel_id(raw_cid) whitelist = load_whitelist() if cid not in whitelist["channels"]: await message.answer(STRINGS["en"]["channel_not_in_whitelist"].format(cid)) return if repo in whitelist["channels"][cid]["allowed_repos"]: whitelist["channels"][cid]["allowed_repos"].remove(repo) if not whitelist["channels"][cid]["allowed_repos"]: del whitelist["channels"][cid] save_whitelist(whitelist) await message.answer(STRINGS["en"]["repo_removed"].format(repo, cid)) else: await message.answer(STRINGS["en"]["repo_not_found"].format(repo, cid)) @router.message(Command("reload")) async def cmd_reload(message: Message): if message.from_user.id not in ADMIN_IDS: return await message.answer(STRINGS["en"]["reload_command"]) data = await fetch_modules_json() await message.answer( STRINGS["en"]["modules_reloaded"].format(len(data) if data else 0) ) # === CHANNEL POST HANDLERS === @router.channel_post(WhitelistChannelFilter(), LimokaTagFilter()) @router.edited_channel_post(WhitelistChannelFilter(), LimokaTagFilter()) async def handle_limoka_tag( message: Message, username: str, repo: str, module_path: str, full_path: str ): logger.info(STRINGS["en"]["limoka_tag_found"].format(full_path)) await process_limoka_tag_directly(message, username, repo, module_path, full_path) async def process_limoka_tag_directly( message: Message, username: str, repo: str, module_path: str, full_path: str ): whitelist = load_whitelist() chat_id = str(message.chat.id) allowed_repos = whitelist["channels"][chat_id].get("allowed_repos", []) repo_full = f"{username}/{repo}" if allowed_repos and repo_full not in allowed_repos: logger.warning(f"Repo {repo_full} blocked for channel {chat_id}") return modules = await get_modules() if not modules or full_path not in modules: logger.warning(f"Module not in DB: {full_path}") return try: # Compute sha256 of remote module content and sign "full_path|sha256" module_url = MODULES_BASE_URL + full_path async with aiohttp.ClientSession() as session: async with session.get(module_url, timeout=10) as resp: if resp.status != 200: logger.error(f"Failed to fetch module for signing: {module_url} (HTTP {resp.status})") return module_bytes = await resp.read() sha256 = hashlib.sha256(module_bytes).hexdigest() payload = f"{full_path}|{sha256}" signature = _security_manager.sign(payload) path_hash = hashlib.sha256(full_path.encode()).hexdigest()[:8] cb_data = f"install:{path_hash}:{signature[:32]}"[:64] keyboard = InlineKeyboardMarkup( inline_keyboard=[ [ InlineKeyboardButton( text=STRINGS["en"]["install_button_text"], callback_data=cb_data ) ] ] ) text = (message.text or message.caption or "").strip() new_text = ( re.sub(r"#limoka:[^\s]+", "", text).strip() or "Module installation available" ) await bot.edit_message_text( chat_id=message.chat.id, message_id=message.message_id, text=new_text, reply_markup=keyboard, ) logger.info(f"βœ… Button added to message {message.message_id}") except TelegramAPIError as e: logger.error(f"Failed to add button: {e}") # === CALLBACKS === @router.callback_query(lambda c: c.data.startswith("install:")) async def process_install(callback: CallbackQuery): try: parts = callback.data.split(":", 3) if len(parts) < 3: await callback.answer( STRINGS["en"]["callback_format_invalid"], show_alert=True ) return _, path_hash, sig_short = parts[:3] modules = await get_modules() if not modules: await callback.answer(STRINGS["en"]["service_unavailable"], show_alert=True) return matches = [ p for p in modules if hashlib.sha256(p.encode()).hexdigest()[:8] == path_hash ] if not matches: await callback.answer( STRINGS["en"]["module_not_in_database"], show_alert=True ) return if len(matches) > 1: await callback.answer(STRINGS["en"]["ambiguous_module"], show_alert=True) logger.warning(STRINGS["en"]["hash_collision"].format(matches)) return module_path = matches[0] # Recompute sha256 for module and verify signature prefix module_url = MODULES_BASE_URL + module_path try: async with aiohttp.ClientSession() as session: async with session.get(module_url, timeout=10) as resp: if resp.status != 200: await callback.answer(STRINGS["en"]["service_unavailable"], show_alert=True) return module_bytes = await resp.read() except Exception: await callback.answer(STRINGS["en"]["service_unavailable"], show_alert=True) return sha256 = hashlib.sha256(module_bytes).hexdigest() expected_sig = _security_manager.sign(f"{module_path}|{sha256}") if not expected_sig.startswith(sig_short): await callback.answer( STRINGS["en"]["signature_verification_failed"], show_alert=True ) logger.warning(STRINGS["en"]["signature_spoof_attempt"].format(module_path)) return install_code = f"#limoka:{module_path}:{expected_sig}" content = as_section(Code(install_code)) await bot.send_message(callback.from_user.id, **content.as_kwargs()) await callback.answer(STRINGS["en"]["message_sent"], show_alert=True) except TelegramForbiddenError: await callback.answer(STRINGS["en"]["cannot_send_messages"], show_alert=True) except Exception as e: logger.exception(f"Callback error: {e}") await callback.answer(f"❌ {type(e).__name__}", show_alert=True) # === INSTALL RESULT CONFIRMATION === @router.message() async def handle_install_result(message: Message): """Handle #limoka:sucsess: and #limoka:failed: from userbot""" if not message.text: return text = message.text.strip() # βœ… Success if text.startswith("#limoka:sucsess:"): parts = text.split(":", 3) if len(parts) >= 3: try: orig_msg_id = int(parts[2]) await bot.delete_message(message.chat.id, orig_msg_id) logger.info(f"βœ… Deleted original message {orig_msg_id} (success)") except Exception as e: logger.warning(f"Failed to delete success source msg {parts[2]}: {e}") await message.answer(STRINGS["en"]["install_success"]) await message.delete() return # βœ… Failed if text.startswith("#limoka:failed:"): parts = text.split(":", 3) if len(parts) >= 3: try: orig_msg_id = int(parts[2]) await bot.delete_message(message.chat.id, orig_msg_id) logger.info(f"βœ… Deleted original message {orig_msg_id} (failure)") except Exception as e: logger.warning(f"Failed to delete failed source msg {parts[2]}: {e}") error_msg = parts[3] if len(parts) > 3 else "Unknown error" await message.answer( STRINGS["en"]["install_failed"].format(error_msg), parse_mode="HTML" ) await message.delete() return # Ignore private non-service messages from non-admin users. # Keep silent (no logs or replies) for non-admin private messages. if message.chat.type == "private" and message.from_user.id not in ADMIN_IDS: return # === STARTUP/SHUTDOWN === async def on_startup(): global _security_manager logger.info(STRINGS["en"]["starting_bot"]) try: _security_manager = SecurityManager(PRIVATE_KEY_FILE) logger.info(STRINGS["en"]["security_manager_initialized"]) except Exception as e: logger.critical(STRINGS["en"]["security_manager_failed"].format(e)) logger.critical(STRINGS["en"]["security_critical_error"]) exit(1) await fetch_modules_json() asyncio.create_task(modules_updater()) for admin in ADMIN_IDS: try: content = as_section( Bold(STRINGS["en"]["startup_notification_title"]), "", *STRINGS["en"]["startup_notification_body"], ) await bot.send_message(admin, **content.as_kwargs()) except Exception as e: logger.error(f"Failed to notify admin {admin}: {e}") async def on_shutdown(): logger.info(STRINGS["en"]["shutting_down"]) await bot.session.close() # === MAIN === async def main(): await on_startup() dp.include_router(router) logger.info(STRINGS["en"]["bot_running"]) try: await dp.start_polling(bot) finally: await on_shutdown() if __name__ == "__main__": asyncio.run(main())