Added buttons

This commit is contained in:
2025-11-23 13:39:45 +03:00
parent fe1b2a208f
commit ab5656d9c2
2 changed files with 889 additions and 4 deletions

214
Limoka.py
View File

@@ -1,5 +1,5 @@
# meta developer: @limokanews
# requires: whoosh
# requires: whoosh cryptography
# Limoka search module.
@@ -38,9 +38,12 @@ import os
import html
import json
import re
import asyncio
from typing import Union, List, Dict, Any, Optional
import hashlib
from telethon.types import Message
from telethon.errors.rpcerrorlist import WebpageMediaEmptyError
@@ -55,7 +58,7 @@ from ..types import InlineCall
logger = logging.getLogger("Limoka")
__version__ = (1, 2, 3)
__version__ = (1, 3, 0)
class Search:
@@ -175,6 +178,12 @@ class Limoka(loader.Module):
"history_cleared": "<emoji document_id=5427009710268689068>🧹</emoji> <b>Search history cleared!</b>",
"invalid_history_arg": "<emoji document_id=5210952531676504517>❌</emoji> <b>Invalid argument for history command. Use:</b>\n<code>.lshistory</code> - show history\n<code>.lshistory clear</code> - clear history",
"close": "❌ Close",
"watcher_no_tag": "❌ Invalid message format. No #limoka tag found.",
"watcher_invalid_format": "❌ Invalid format. Expected: #limoka:path:signature",
"watcher_signature_invalid": "❌ Signature invalid! Installation aborted.",
"watcher_loader_missing": "❌ Loader module not found.",
"watcher_module_not_found": "❌ Module not found in Limoka database: <code>{path}</code>",
"watcher_critical": "❌ Critical error: {error}",
}
strings_ru = {
@@ -217,6 +226,10 @@ class Limoka(loader.Module):
"facts": [
"<emoji document_id=5472193350520021357>🛡</emoji> Каталог Limoka тщательно модерируется!",
"<emoji document_id=5940434198413184876>🚀</emoji> Limoka позволяет искать модули с невероятной скоростью!",
(
"<emoji document_id=5188311512791393083>🔎</emoji> Limoka имеет лучший поиск*!"
"\n <i>* В сравнении с предыдущей версией Limoka</i>"
)
],
"inline404": "Не найдено",
"inline?": "Запрос слишком короткий / не найден",
@@ -261,6 +274,12 @@ class Limoka(loader.Module):
"history_cleared": "<emoji document_id=5427009710268689068>🧹</emoji> <b>История поиска очищена!</b>",
"invalid_history_arg": "<emoji document_id=5210952531676504517>❌</emoji> <b>Неверный аргумент для команды истории. Используйте:</b>\n<code>.lshistory</code> - показать историю\n<code>.lshistory clear</code> - очистить историю",
"close": "❌ Закрыть",
"watcher_no_tag": "❌ Неверный формат сообщения. Тег #limoka не найден.",
"watcher_invalid_format": "❌ Неверный формат. Ожидается: #limoka:path:signature",
"watcher_signature_invalid": "❌ Неверная подпись! Установка отменена.",
"watcher_loader_missing": "❌ Модуль загрузчика не найден.",
"watcher_module_not_found": "❌ Модуль не найден в базе Limoka: <code>{path}</code>",
"watcher_critical": "❌ Критическая ошибка: {error}",
"_cls_doc": "Модули теперь в одном месте с простым и удобным поиском!",
}
@@ -270,8 +289,14 @@ class Limoka(loader.Module):
loader.ConfigValue(
"limokaurl",
"https://raw.githubusercontent.com/MuRuLOSE/limoka/refs/heads/main/",
lambda: "Зеркало (не работает): https://raw.githubusercontent.com/MuRuLOSE/limoka-mirror/refs/heads/main/",
lambda: "Mirror (doesn't work): https://raw.githubusercontent.com/MuRuLOSE/limoka-mirror/refs/heads/main/",
validator=loader.validators.String(),
),
loader.ConfigValue(
"external_install",
True,
lambda: "If enabled, module installation can be handled via external Limoka bot (@limoka_bbot) for better reliability.",
validator=loader.validators.Boolean(),
)
)
self.name = self.strings["name"]
@@ -1237,3 +1262,186 @@ class Limoka(loader.Module):
message,
self.strings["history"].format(history="\n".join(formatted_history)),
)
@loader.watcher(from_dl=False)
async def secure_install_watcher(self, message: Message):
"""Secure install watcher for official Limoka bot.
This watcher cleans HTML from incoming messages, extracts a
signed #limoka:<path>:<signature> tag, verifies the signature and
triggers the loader to download and install the module if valid.
"""
if not message.text:
return
# Verify sender id is present and comes from the official Limoka bot
if not hasattr(message, "from_id") or not message.from_id:
return
sender_id = None
if hasattr(message.from_id, "user_id"):
sender_id = message.from_id.user_id
elif hasattr(message.from_id, "channel_id"):
sender_id = message.from_id.channel_id
if sender_id != 7538432559:
logger.debug("Message not from official bot, ignoring")
return
# Only act when external installs are enabled
if not self.config["external_install"]:
return
try:
# Prefer raw_text/message when available to preserve original
# formatting (some clients provide parsed .text that loses
# tags/links). Fall back to .text if needed.
clean_text = getattr(message, "raw_text", None) or getattr(
message, "message", None
) or message.text or ""
if message.entities:
from html import unescape
clean_text = unescape(clean_text)
# Remove HTML tags but keep their inner text so we don't
# accidentally remove the tag content when it's wrapped
# in an <a> or similar.
clean_text = re.sub(r"<[^>]+>", "", clean_text)
# Extract the first #limoka:<content> occurrence. Allow for
# characters until whitespace or HTML/quote delimiters.
match = re.search(r"#limoka:([^\s\"'<>]+)", clean_text)
if not match:
logger.debug(
"No #limoka tag found in cleaned text; leaving original message intact"
)
# Do not send a user-visible reply for missing tag; simply exit.
return
tag_content = match.group(1)
# Expect format: <path>:<hex_signature>
parts = tag_content.split(":", 1)
if len(parts) != 2:
logger.error("Invalid tag format after cleaning")
await utils.answer(message, self.strings["watcher_invalid_format"])
# Do not delete the original message on parse errors.
return
module_path, signature_hex = parts
# Strip leftover quote characters and whitespace
module_path = re.sub(r"[<>\"']", "", module_path).strip()
# Handle possible href= artifacts
if module_path.startswith("href="):
module_path = module_path[5:].strip('"').strip("'")
# Try to resolve the module key in database
if module_path not in self.modules:
found = False
for db_path in self.modules.keys():
if module_path in db_path or db_path in module_path:
module_path = db_path
found = True
break
if not found:
logger.warning(f"Module not found after cleanup: {module_path}")
await utils.answer(
message, self.strings["watcher_module_not_found"].format(path=html.escape(module_path))
)
# Keep original message in chat for inspection.
return
# logger.info(f"Module found in database: {module_path}")
# Verify signature using embedded public key — signature covers
# the module path AND the SHA256 of the module content (format:
# "{module_path}|{sha256}"). Download module, compute hash and
# verify signature against that combined payload.
try:
import base64
from cryptography.hazmat.primitives.asymmetric import ed25519
PUB_KEY_B64 = "MCowBQYDK2VwAyEA1ltSnqtf3pGBuctuAYqHivCXsaRtKOVxavai7yin7ZE="
der_bytes = base64.b64decode(PUB_KEY_B64)
raw_pubkey = der_bytes[-32:]
# Download module content to compute SHA256
module_url = self.config["limokaurl"] + module_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 verification: {module_url} (HTTP {resp.status})")
await utils.answer(message, self.strings["watcher_loader_missing"])
return
module_bytes = await resp.read()
sha256 = hashlib.sha256(module_bytes).hexdigest()
public_key = ed25519.Ed25519PublicKey.from_public_bytes(raw_pubkey)
signature = bytes.fromhex(signature_hex)
signed_payload = f"{module_path}|{sha256}".encode()
public_key.verify(signature, signed_payload)
logger.info(f"Signature verified for {module_path} (sha256={sha256})")
except Exception as e:
logger.error(f"Signature verification failed for {module_path}: {e}")
await utils.answer(message, self.strings["watcher_signature_invalid"])
# Keep original message so admins can inspect the signed payload.
return
# Perform install via loader
loader_mod = self.lookup("loader")
if not loader_mod:
logger.error("Loader module not found")
await utils.answer(message, self.strings["watcher_loader_missing"])
# Do not delete the original message on loader problems.
return
module_url = self.config["limokaurl"] + module_path
# logger.info(f"Installing from URL: {module_url}")
status = await loader_mod.download_and_install(module_url, None)
if getattr(loader_mod, "fully_loaded", False):
loader_mod.update_modules_in_db()
# Attempt to remove the original message
try:
await message.delete()
# logger.info("Original message deleted")
except Exception as e:
logger.error(f"Failed to delete message: {e}")
logger.info(status)
if status:
# module_name = module_path.split("/")[-1].replace(".py", "")
# Notify official bot about success
try:
bot_peer = await self.client.get_entity(7538432559)
await self.client.send_message(bot_peer, f"#limoka:sucsess:{message.id}")
# logger.info(f"Sent success confirmation to bot for message {message.id}")
except Exception as e:
logger.error(f"Failed to send success confirmation: {e}")
# logger.info(f"Module {module_name} installed successfully")
else:
logger.error(f"Installation failed with status: {status}")
try:
bot_peer = await self.client.get_entity(7538432559)
await self.client.send_message(bot_peer, f"#limoka:failed:{message.id}")
# logger.info(f"Sent failure notification to bot for message {message.id}")
except Exception as e:
logger.error(f"Failed to send failure notification: {e}")
except Exception as e:
logger.exception(f"CRITICAL ERROR in secure_install_watcher: {e}")
try:
await utils.answer(message, self.strings["watcher_critical"].format(error=str(e)[:100]))
await asyncio.sleep(5)
await message.delete()
except Exception:
pass

677
assets/bot/main.py Normal file
View File

@@ -0,0 +1,677 @@
"""
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 <channel_id> <repo> — Add repo to channel",
"/rmrepo <channel_id> <repo> — 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 <id> — Add channel to whitelist",
"/rmchannel <id> — Remove channel from whitelist",
"/reload — Reload modules.json",
],
"addchannel_usage": "❌ Usage: /addchannel <channel_id>",
"rmchannel_usage": "❌ Usage: /rmchannel <channel_id>",
"addrepo_usage": "❌ Usage: /addrepo <channel_id> <repo>\nExample: /addrepo -1003377102183 MuRuLOSE/limoka-modules",
"rmrepo_usage": "❌ Usage: /rmrepo <channel_id> <repo>\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<code>{}</code>",
# 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 <channel_id> <repo>"))
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 <channel_id> <repo>"))
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())