From 5926a38e479d0cbdfc5c8ea6c95e8859f914a991 Mon Sep 17 00:00:00 2001 From: overspend1 Date: Sun, 21 Dec 2025 17:03:20 +0100 Subject: [PATCH] Initial commit --- .gitignore | 176 +----------- README.md | 29 +- __main__.py | 19 ++ config/config.yml | 131 +++++++++ config/modules.yml | 75 +++++ core/__init__.py | 0 core/app.py | 331 +++++++++++++++++++++ core/audit.py | 8 + core/backup.py | 55 ++++ core/backup_service.py | 78 +++++ core/bus.py | 46 +++ core/cache.py | 54 ++++ core/changelog.py | 73 +++++ core/cli.py | 167 +++++++++++ core/client.py | 100 +++++++ core/commands.py | 312 ++++++++++++++++++++ core/config.py | 213 ++++++++++++++ core/config_ui.py | 17 ++ core/database.py | 308 ++++++++++++++++++++ core/error_handler.py | 18 ++ core/events.py | 46 +++ core/gitea.py | 33 +++ core/http.py | 66 +++++ core/loader.py | 476 +++++++++++++++++++++++++++++++ core/logger.py | 106 +++++++ core/migrations.py | 109 +++++++ core/module.py | 30 ++ core/module_updates.py | 38 +++ core/monitor.py | 21 ++ core/notifications.py | 46 +++ core/permissions.py | 50 ++++ core/plugin.py | 143 ++++++++++ core/profiler.py | 14 + core/rate_limiter.py | 23 ++ core/sandbox.py | 29 ++ core/scheduler.py | 42 +++ core/testing.py | 80 ++++++ core/update_service.py | 238 ++++++++++++++++ core/updater.py | 234 +++++++++++++++ core/version.py | 43 +++ core/versioning.py | 18 ++ core/webhook.py | 18 ++ core/webhook_server.py | 46 +++ docs/API_REFERENCE.md | 49 ++++ docs/BEST_PRACTICES.md | 8 + docs/EXAMPLES.md | 26 ++ docs/GITEA_SETUP.md | 73 +++++ docs/PLUGIN_GUIDE.md | 49 ++++ modules/__init__.py | 0 modules/admin/API.md | 3 + modules/admin/COMMANDS.md | 15 + modules/admin/CONFIG.md | 3 + modules/admin/README.md | 3 + modules/admin/__init__.py | 306 ++++++++++++++++++++ modules/admin/analytics.py | 10 + modules/admin/backup.py | 10 + modules/admin/moderation.py | 10 + modules/admin/welcome.py | 10 + modules/automation/API.md | 3 + modules/automation/COMMANDS.md | 9 + modules/automation/CONFIG.md | 3 + modules/automation/README.md | 3 + modules/automation/__init__.py | 118 ++++++++ modules/automation/afk.py | 10 + modules/automation/auto_reply.py | 10 + modules/automation/forwarding.py | 10 + modules/automation/scheduler.py | 10 + modules/developer/API.md | 3 + modules/developer/COMMANDS.md | 17 ++ modules/developer/CONFIG.md | 3 + modules/developer/README.md | 3 + modules/developer/__init__.py | 331 +++++++++++++++++++++ modules/developer/api.py | 10 + modules/developer/code.py | 10 + modules/developer/debug.py | 10 + modules/fun/API.md | 3 + modules/fun/COMMANDS.md | 10 + modules/fun/CONFIG.md | 3 + modules/fun/README.md | 3 + modules/fun/__init__.py | 81 ++++++ modules/fun/games.py | 10 + modules/fun/jokes.py | 10 + modules/fun/random.py | 10 + modules/media/API.md | 3 + modules/media/COMMANDS.md | 11 + modules/media/CONFIG.md | 3 + modules/media/README.md | 3 + modules/media/__init__.py | 141 +++++++++ modules/media/compress.py | 10 + modules/media/convert.py | 10 + modules/media/download.py | 10 + modules/media/edit.py | 10 + modules/media/stickers.py | 10 + modules/search/API.md | 3 + modules/search/COMMANDS.md | 10 + modules/search/CONFIG.md | 3 + modules/search/README.md | 3 + modules/search/__init__.py | 111 +++++++ modules/search/dictionary.py | 10 + modules/search/media.py | 10 + modules/search/translate.py | 10 + modules/search/web.py | 10 + modules/text/API.md | 3 + modules/text/COMMANDS.md | 14 + modules/text/CONFIG.md | 3 + modules/text/README.md | 3 + modules/text/__init__.py | 123 ++++++++ modules/text/analysis.py | 10 + modules/text/conversion.py | 10 + modules/text/encoding.py | 10 + modules/text/formatting.py | 10 + modules/text/generation.py | 10 + modules/utility/API.md | 3 + modules/utility/COMMANDS.md | 13 + modules/utility/CONFIG.md | 3 + modules/utility/README.md | 3 + modules/utility/__init__.py | 465 ++++++++++++++++++++++++++++++ modules/utility/calculator.py | 10 + modules/utility/converter.py | 10 + modules/utility/crypto.py | 10 + modules/utility/notes.py | 10 + modules/utility/reminders.py | 10 + modules/utility/weather.py | 10 + plugins/__init__.py | 0 plugins/external/.gitkeep | 0 requirements.txt | 12 + scripts/create-release.sh | 19 ++ scripts/setup-branches.sh | 19 ++ scripts/setup-gitea.sh | 70 +++++ scripts/update-bot.sh | 12 + tests/test_commands.py | 22 ++ tests/test_config.py | 17 ++ 132 files changed, 6552 insertions(+), 170 deletions(-) create mode 100644 __main__.py create mode 100644 config/config.yml create mode 100644 config/modules.yml create mode 100644 core/__init__.py create mode 100644 core/app.py create mode 100644 core/audit.py create mode 100644 core/backup.py create mode 100644 core/backup_service.py create mode 100644 core/bus.py create mode 100644 core/cache.py create mode 100644 core/changelog.py create mode 100644 core/cli.py create mode 100644 core/client.py create mode 100644 core/commands.py create mode 100644 core/config.py create mode 100644 core/config_ui.py create mode 100644 core/database.py create mode 100644 core/error_handler.py create mode 100644 core/events.py create mode 100644 core/gitea.py create mode 100644 core/http.py create mode 100644 core/loader.py create mode 100644 core/logger.py create mode 100644 core/migrations.py create mode 100644 core/module.py create mode 100644 core/module_updates.py create mode 100644 core/monitor.py create mode 100644 core/notifications.py create mode 100644 core/permissions.py create mode 100644 core/plugin.py create mode 100644 core/profiler.py create mode 100644 core/rate_limiter.py create mode 100644 core/sandbox.py create mode 100644 core/scheduler.py create mode 100644 core/testing.py create mode 100644 core/update_service.py create mode 100644 core/updater.py create mode 100644 core/version.py create mode 100644 core/versioning.py create mode 100644 core/webhook.py create mode 100644 core/webhook_server.py create mode 100644 docs/API_REFERENCE.md create mode 100644 docs/BEST_PRACTICES.md create mode 100644 docs/EXAMPLES.md create mode 100644 docs/GITEA_SETUP.md create mode 100644 docs/PLUGIN_GUIDE.md create mode 100644 modules/__init__.py create mode 100644 modules/admin/API.md create mode 100644 modules/admin/COMMANDS.md create mode 100644 modules/admin/CONFIG.md create mode 100644 modules/admin/README.md create mode 100644 modules/admin/__init__.py create mode 100644 modules/admin/analytics.py create mode 100644 modules/admin/backup.py create mode 100644 modules/admin/moderation.py create mode 100644 modules/admin/welcome.py create mode 100644 modules/automation/API.md create mode 100644 modules/automation/COMMANDS.md create mode 100644 modules/automation/CONFIG.md create mode 100644 modules/automation/README.md create mode 100644 modules/automation/__init__.py create mode 100644 modules/automation/afk.py create mode 100644 modules/automation/auto_reply.py create mode 100644 modules/automation/forwarding.py create mode 100644 modules/automation/scheduler.py create mode 100644 modules/developer/API.md create mode 100644 modules/developer/COMMANDS.md create mode 100644 modules/developer/CONFIG.md create mode 100644 modules/developer/README.md create mode 100644 modules/developer/__init__.py create mode 100644 modules/developer/api.py create mode 100644 modules/developer/code.py create mode 100644 modules/developer/debug.py create mode 100644 modules/fun/API.md create mode 100644 modules/fun/COMMANDS.md create mode 100644 modules/fun/CONFIG.md create mode 100644 modules/fun/README.md create mode 100644 modules/fun/__init__.py create mode 100644 modules/fun/games.py create mode 100644 modules/fun/jokes.py create mode 100644 modules/fun/random.py create mode 100644 modules/media/API.md create mode 100644 modules/media/COMMANDS.md create mode 100644 modules/media/CONFIG.md create mode 100644 modules/media/README.md create mode 100644 modules/media/__init__.py create mode 100644 modules/media/compress.py create mode 100644 modules/media/convert.py create mode 100644 modules/media/download.py create mode 100644 modules/media/edit.py create mode 100644 modules/media/stickers.py create mode 100644 modules/search/API.md create mode 100644 modules/search/COMMANDS.md create mode 100644 modules/search/CONFIG.md create mode 100644 modules/search/README.md create mode 100644 modules/search/__init__.py create mode 100644 modules/search/dictionary.py create mode 100644 modules/search/media.py create mode 100644 modules/search/translate.py create mode 100644 modules/search/web.py create mode 100644 modules/text/API.md create mode 100644 modules/text/COMMANDS.md create mode 100644 modules/text/CONFIG.md create mode 100644 modules/text/README.md create mode 100644 modules/text/__init__.py create mode 100644 modules/text/analysis.py create mode 100644 modules/text/conversion.py create mode 100644 modules/text/encoding.py create mode 100644 modules/text/formatting.py create mode 100644 modules/text/generation.py create mode 100644 modules/utility/API.md create mode 100644 modules/utility/COMMANDS.md create mode 100644 modules/utility/CONFIG.md create mode 100644 modules/utility/README.md create mode 100644 modules/utility/__init__.py create mode 100644 modules/utility/calculator.py create mode 100644 modules/utility/converter.py create mode 100644 modules/utility/crypto.py create mode 100644 modules/utility/notes.py create mode 100644 modules/utility/reminders.py create mode 100644 modules/utility/weather.py create mode 100644 plugins/__init__.py create mode 100644 plugins/external/.gitkeep create mode 100644 requirements.txt create mode 100755 scripts/create-release.sh create mode 100755 scripts/setup-branches.sh create mode 100755 scripts/setup-gitea.sh create mode 100755 scripts/update-bot.sh create mode 100644 tests/test_commands.py create mode 100644 tests/test_config.py diff --git a/.gitignore b/.gitignore index 36b13f1..f6c526d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,176 +1,16 @@ -# ---> Python -# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: *.log -local_settings.py -db.sqlite3 -db.sqlite3-journal +*.db +*.sqlite +*.sqlite3 -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv +.venv/ env/ venv/ -ENV/ -env.bak/ -venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +data/ +backups/ +plugins/external/* +!plugins/external/.gitkeep diff --git a/README.md b/README.md index be0ccbb..33e0fd5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,28 @@ -# overub +# OverUB -OverUB - Advanced Modular Telegram Userbot by @overspend1 \ No newline at end of file +OverUB is a modular Telegram userbot built around a plugin-first architecture. + +## Quick Start +1. Install dependencies: `pip install -r requirements.txt` +2. Edit `config/config.yml` +3. Run: `python -m __main__` + +## CLI +- `python -m __main__ create-plugin ` +- `python -m __main__ validate-plugin ` +- `python -m __main__ build-plugin ` +- `python -m __main__ docs-plugin ` +- `python -m __main__ test-plugin ` + +## Structure +See the `core/` package for the minimal base runtime and `modules/` for built-ins. + +## Tests +- `python -m unittest discover -s tests` + +## Docs +- `docs/PLUGIN_GUIDE.md` +- `docs/API_REFERENCE.md` +- `docs/EXAMPLES.md` +- `docs/BEST_PRACTICES.md` +- `docs/GITEA_SETUP.md` diff --git a/__main__.py b/__main__.py new file mode 100644 index 0000000..f4d901a --- /dev/null +++ b/__main__.py @@ -0,0 +1,19 @@ +import asyncio +import sys + +from core.app import main +from core.cli import run_cli + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] in { + "create-plugin", + "validate-plugin", + "test-plugin", + "build-plugin", + "docs-plugin", + "publish-plugin", + }: + run_cli() + else: + asyncio.run(main()) diff --git a/config/config.yml b/config/config.yml new file mode 100644 index 0000000..17ba59c --- /dev/null +++ b/config/config.yml @@ -0,0 +1,131 @@ +# OverUB main configuration +bot: + api_id: 123456 + api_hash: "your_hash" + session_name: "overub" + phone: "+1234567890" + command_prefix: "." + instances: [] + +config_version: "1.0.0" + +logging: + level: "INFO" + json: false + module_logs: false + remote_url: "" + +modules: + enabled_categories: + - text + - media + - utility + - search + - admin + - fun + - automation + - developer + disabled_modules: [] + +plugins: + enabled: true + auto_update: true + allow_third_party: true + plugin_path: "plugins/external" + +database: + type: "sqlite" + path: "data/database.db" + backup: true + backup_interval: 86400 + dsn: "" + uri: "" + url: "" + database: "overub" + +updates: + enabled: true + git: + remote: "https://gitea.yourdomain.com/overspend1/overub.git" + token: "" + use_ssh: false + ssh_key: "~/.ssh/id_rsa" + gitea: + api_url: "https://gitea.yourdomain.com/api/v1" + token: "" + use_releases: true + webhook_secret: "" + channel: "stable" + branch: "main" + auto_update: true + check_interval: 3600 + update_time: "03:00" + notify: true + backup_before_update: true + max_rollback_versions: 3 + verify_commits: true + allowed_sources: + - "https://gitea.yourdomain.com" + modules: + path: "modules" + remote: "origin" + branch: "main" + webhook: + enabled: false + host: "0.0.0.0" + port: 8080 + +scheduler: + auto_update_time: "03:00" + max_downtime: 120 + postpone_on_activity: true + retry_failed: true + retry_interval: 3600 + +notifications: + enabled: true + channels: + - telegram + events: + - update_available + - update_completed + - update_failed + quiet_hours: + start: "22:00" + end: "08:00" + +services: + youtube_api_key: "" + translate_url: "" + +security: + allowed_users: [] + blocked_users: [] + sudo_users: [] + plugin_error_limit: 3 + secret_key: "" + plugin_network: false + allowed_signers: [] + verify_plugin_commits: false + +performance: + max_memory: 512 + max_cpu: 50 + cache_size: 100 + rate_limits: + command: + limit: 5 + window: 5 + +backup: + auto: false + schedule: "03:30" + check_interval: 60 + scopes: + - core + - modules + - plugins + max_downtime: 120 + postpone_on_activity: true + retry_failed: true + retry_interval: 3600 diff --git a/config/modules.yml b/config/modules.yml new file mode 100644 index 0000000..ea56d8c --- /dev/null +++ b/config/modules.yml @@ -0,0 +1,75 @@ +modules: + text: + enabled: true + sub_modules: + formatting: true + conversion: true + generation: false + encoding: true + analysis: true + settings: + max_text_length: 4096 + + media: + enabled: true + settings: + download_path: "data/downloads" + max_file_size: 2000000000 + default_quality: "high" + sub_modules: + download: true + convert: true + edit: true + stickers: true + compress: true + + utility: + enabled: true + settings: + notes_limit: 100 + reminders_limit: 50 + sub_modules: + notes: true + reminders: true + calculator: true + converter: true + weather: true + crypto: true + + search: + enabled: true + sub_modules: + web: true + media: true + translate: true + dictionary: true + + admin: + enabled: true + sub_modules: + moderation: true + welcome: true + analytics: true + backup: true + + fun: + enabled: true + sub_modules: + games: true + random: true + jokes: true + + automation: + enabled: true + sub_modules: + scheduler: true + afk: true + auto_reply: true + forwarding: true + + developer: + enabled: true + sub_modules: + code: true + api: true + debug: true diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/app.py b/core/app.py new file mode 100644 index 0000000..c79005f --- /dev/null +++ b/core/app.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from datetime import datetime +from typing import Any, Dict, Optional + +from core.backup import BackupManager +from core.backup_service import BackupService +from core.bus import MessageBus +from core.cache import Cache +from core.rate_limiter import RateLimit, RateLimiter +from core.sandbox import Sandbox +from core.gitea import GiteaClient +from core.http import SessionManager +from core.migrations import MigrationManager +from core.webhook_server import WebhookServer +from core.module_updates import ModuleUpdateManager +from core.update_service import UpdateService +from core.version import VersionManager +from core.client import ClientWrapper +from core.commands import CommandBuilder, CommandRegistry +from core.config import ConfigManager +from core.database import DatabaseManager +from core.events import EventDispatcher +from core.loader import ModuleLoader, PluginManager +from core.logger import get_logger, setup_logging +from core.permissions import PermissionManager +from core.updater import UpdateManager + + +logger = get_logger("core.app") + + +class OverUB: + def __init__(self, root: Path, bot_override: Optional[Dict[str, Any]] = None) -> None: + self.root = root + self.config = ConfigManager(root / "config" / "config.yml", root / "config" / "modules.yml") + self.config.load() + if bot_override: + self.config.merge({"bot": bot_override}) + + log_cfg = self.config.get().get("logging", {}) + log_level = log_cfg.get("level", "INFO") + setup_logging( + log_level, + json_logs=bool(log_cfg.get("json", False)), + module_logs=bool(log_cfg.get("module_logs", False)), + remote_url=str(log_cfg.get("remote_url", "")), + ) + + self.events = EventDispatcher() + self.bus = MessageBus() + self.permissions = PermissionManager() + self.commands = CommandRegistry(prefix=self.config.get().get("bot", {}).get("command_prefix", ".")) + self.command_builder = CommandBuilder(self.commands) + + self.permissions.load_from_config(self.config.get().get("security", {})) + + cache_size = self.config.get().get("performance", {}).get("cache_size", 100) + self.cache = Cache(max_size=int(cache_size)) + self.backups = BackupManager(root) + self.rate_limiter = RateLimiter() + self.versions = VersionManager(self) + sandbox_root = root / "plugins" + sandbox_cfg = self.config.get().get("security", {}) + self.sandbox = Sandbox( + sandbox_root, + allow_network=bool(sandbox_cfg.get("plugin_network", False)), + ) + self.update_service = UpdateService(self) + self.backup_service = BackupService(self) + self.http = SessionManager() + self.migrations = MigrationManager(root) + + db_cfg = self.config.get().get("database", {}) + db_path = Path(db_cfg.get("path", "data/database.db")) + self.database = DatabaseManager(db_cfg.get("type", "sqlite"), db_cfg, root / db_path) + + updates = self.config.get().get("updates", {}) + git_cfg = updates.get("git", {}) + self.updater = UpdateManager(root, git_cfg.get("remote", "origin"), updates.get("branch", "main")) + modules_cfg = updates.get("modules", {}) + self.module_updates = ModuleUpdateManager( + root / modules_cfg.get("path", "modules"), + modules_cfg.get("remote", "origin"), + modules_cfg.get("branch", updates.get("branch", "main")), + ) + gitea_cfg = updates.get("gitea", {}) + self.gitea = GiteaClient(gitea_cfg.get("api_url", ""), gitea_cfg.get("token", "")) + self.updater.gitea = self.gitea + webhook_cfg = updates.get("webhook", {}) + self.webhook_server = WebhookServer( + self, + webhook_cfg.get("host", "0.0.0.0"), + int(webhook_cfg.get("port", 8080)), + ) + self.webhook_enabled = bool(webhook_cfg.get("enabled", False)) + + bot_cfg = self.config.get().get("bot", {}) + self.client = ClientWrapper( + api_id=int(bot_cfg.get("api_id", 0)), + api_hash=str(bot_cfg.get("api_hash", "")), + session_name=str(bot_cfg.get("session_name", "overub")), + ) + + self.modules = ModuleLoader(self, root / "modules") + self.plugins = PluginManager(self, root / "plugins" / "external") + self.last_activity = None + + async def start(self) -> None: + await self.events.emit("on_startup") + await self.database.connect() + await self.migrations.apply(self) + await self.client.connect() + await self.client.attach_handlers(self) + await self._load_builtin_modules() + await self._load_plugins() + if self.config.get().get("updates", {}).get("enabled", False): + self.update_service.start() + self.backup_service.start() + if self.webhook_enabled: + await self.webhook_server.start() + await self.events.emit("on_ready") + logger.info("OverUB ready") + + async def shutdown(self) -> None: + await self.events.emit("on_shutdown") + await self.update_service.stop() + await self.backup_service.stop() + if self.webhook_enabled: + await self.webhook_server.stop() + await self.http.close() + await self.client.disconnect() + await self.database.close() + logger.info("OverUB shutdown") + + async def handle_message_event(self, event: Any) -> None: + message = getattr(event, "message", event) + text = getattr(message, "raw_text", "") or "" + user_id = getattr(event, "sender_id", None) or getattr(message, "sender_id", 0) or 0 + chat_id = getattr(event, "chat_id", None) or getattr(message, "chat_id", 0) or 0 + chat_type = self._get_chat_type(event) + self.last_activity = datetime.utcnow() + + parsed = self.commands.parse(text) + if parsed: + command_event = await self.events.emit("on_command", message=message, event=event, parsed=parsed) + if not command_event.cancelled: + command, args = self.commands.resolve(parsed.name, parsed.args) + if command: + event.command_flags = self._apply_flag_specs(command, parsed.flags) + event.command_args = args + event.command_raw = parsed.raw + event.command_prefix = parsed.prefix + if command.arguments: + try: + event.command_parsed_args = self._apply_argument_specs(command, args) + except ValueError as exc: + await self._reply(message, str(exc)) + return + if command.chat_types and chat_type not in command.chat_types: + await self._reply(message, "Command not available here") + elif not self.permissions.is_allowed(command.permission, user_id, chat_id): + await self._reply(message, "Permission denied") + else: + plugin_cfg = self._plugin_config_for_command(command) + if plugin_cfg and not self._plugin_allowed(plugin_cfg, user_id, chat_id): + await self._reply(message, "Plugin permission denied") + return + remaining = self.commands.cooldown_remaining(command, user_id) + plugin_cooldown = int(plugin_cfg.get("cooldown", 0)) if plugin_cfg else 0 + remaining = max(remaining, plugin_cooldown) + if remaining > 0: + await self._reply(message, f"Cooldown: {remaining}s") + else: + if not self._rate_limit(command, user_id): + await self._reply(message, "Rate limit exceeded") + return + await command.handler(event, args) + await self.events.emit("on_message_new", message=message, event=event) + if getattr(event, "out", False): + await self.events.emit("on_message_sent", message=message, event=event) + + async def handle_edit_event(self, event: Any) -> None: + message = getattr(event, "message", event) + self.last_activity = datetime.utcnow() + await self.events.emit("on_message_edit", message=message, event=event) + + async def handle_delete_event(self, event: Any) -> None: + self.last_activity = datetime.utcnow() + await self.events.emit("on_message_delete", event=event) + + async def _load_builtin_modules(self) -> None: + module_config = self.config.get_modules().get("modules", {}) + for name, cfg in module_config.items(): + if not cfg.get("enabled", True): + continue + module_path = f"modules.{name}" + await self.modules.load(module_path) + sub_modules = cfg.get("sub_modules", {}) + for sub_name, enabled in sub_modules.items(): + if enabled: + await self.modules.load(f"modules.{name}.{sub_name}") + + async def _load_plugins(self) -> None: + plugin_cfg = self.config.get().get("plugins", {}) + if not plugin_cfg.get("enabled", True): + return + plugin_dir = Path(plugin_cfg.get("plugin_path", "plugins/external")) + if not plugin_dir.exists(): + return + for item in plugin_dir.iterdir(): + if item.is_dir() and (item / "__init__.py").exists(): + await self.plugins.load(item.name) + + def register_permission_profile(self, name: str, users: list[int], chats: list[int]) -> None: + from core.permissions import PermissionProfile + + self.permissions.add_profile(PermissionProfile(name=name, users=users, chats=chats)) + + def _get_chat_type(self, event: Any) -> str: + if getattr(event, "is_private", False): + return "private" + if getattr(event, "is_group", False): + return "group" + if getattr(event, "is_channel", False): + return "channel" + return "unknown" + + async def _reply(self, message: Any, text: str) -> None: + if hasattr(message, "reply"): + await message.reply(text) + + def _plugin_config_for_command(self, command: Any) -> Dict[str, Any]: + if getattr(command, "owner_type", "") != "plugin": + return {} + return self.config.get_plugin_config(getattr(command, "owner", "")) + + def _plugin_allowed(self, plugin_cfg: Dict[str, Any], user_id: int, chat_id: int) -> bool: + allowed = plugin_cfg.get("permissions", []) or [] + level = plugin_cfg.get("permission_level", "user") + if not self.permissions.is_allowed(level, user_id, chat_id): + return False + if not allowed: + return True + return user_id in allowed or chat_id in allowed + + def _rate_limit(self, command: Any, user_id: int) -> bool: + limits = self.config.get().get("performance", {}).get("rate_limits", {}) + command_limit = limits.get("command", {"limit": 5, "window": 5}) + limit = RateLimit(limit=int(command_limit["limit"]), window=int(command_limit["window"])) + return self.rate_limiter.check(f"command:{command.name}", user_id, limit) + + def _apply_argument_specs(self, command: Any, args: list[str]) -> Dict[str, Any]: + parsed: Dict[str, Any] = {} + idx = 0 + for spec in command.arguments: + if spec.variadic: + remaining = args[idx:] + parsed[spec.name] = [spec.arg_type(item) for item in remaining] + idx = len(args) + break + if idx >= len(args): + if spec.required: + raise ValueError(f"Missing argument: {spec.name}") + parsed[spec.name] = spec.default + continue + parsed[spec.name] = spec.arg_type(args[idx]) + idx += 1 + return parsed + + def _apply_flag_specs(self, command: Any, flags: Dict[str, Any]) -> Dict[str, Any]: + if not command.flags: + return flags + result = dict(flags) + for spec in command.flags: + value = None + if spec.name in result: + value = result[spec.name] + else: + for alias in spec.aliases: + if alias in result: + value = result[alias] + break + if value is None: + result[spec.name] = spec.default + continue + try: + result[spec.name] = spec.flag_type(value) + except Exception: + result[spec.name] = spec.default + return result + + +async def main() -> None: + root = Path(__file__).resolve().parents[1] + cfg = ConfigManager(root / "config" / "config.yml", root / "config" / "modules.yml") + cfg.load() + instances = cfg.get().get("bot", {}).get("instances", []) + apps = [] + if instances: + for instance_cfg in instances: + apps.append(OverUB(root, bot_override=instance_cfg)) + else: + apps.append(OverUB(root)) + + async def run_instance(app: OverUB) -> None: + backoff = 3 + while True: + try: + await app.start() + await app.client.wait_until_disconnected() + except Exception: + logger.exception("Instance failed, restarting") + finally: + await app.shutdown() + await asyncio.sleep(backoff) + + tasks = [asyncio.create_task(run_instance(app)) for app in apps] + try: + await asyncio.gather(*tasks) + except KeyboardInterrupt: + logger.info("Interrupted") + finally: + for task in tasks: + task.cancel() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/core/audit.py b/core/audit.py new file mode 100644 index 0000000..23ce532 --- /dev/null +++ b/core/audit.py @@ -0,0 +1,8 @@ +from core.logger import get_logger + + +logger = get_logger("audit") + + +def log(action: str, details: str) -> None: + logger.info("%s %s", action, details) diff --git a/core/backup.py b/core/backup.py new file mode 100644 index 0000000..6cb1005 --- /dev/null +++ b/core/backup.py @@ -0,0 +1,55 @@ +import tarfile +from datetime import datetime +from pathlib import Path +from typing import List + +from core.logger import get_logger + + +logger = get_logger("core.backup") + + +class BackupManager: + def __init__(self, root: Path) -> None: + self.root = root + self.backup_root = root / "backups" + self.backup_root.mkdir(parents=True, exist_ok=True) + + def create(self, scope: str) -> Path: + timestamp = datetime.utcnow().strftime("%Y-%m-%d_%H%M%S") + if scope == "core": + sources = ["core", "config", "requirements.txt", "README.md", "__main__.py"] + dest_dir = self.backup_root / "core" + name = f"core_{timestamp}.tar.gz" + elif scope == "modules": + sources = ["modules"] + dest_dir = self.backup_root / "modules" + name = f"modules_{timestamp}.tar.gz" + elif scope == "plugins": + sources = ["plugins/external"] + dest_dir = self.backup_root / "plugins" + name = f"plugins_{timestamp}.tar.gz" + else: + raise ValueError("Unknown backup scope") + + dest_dir.mkdir(parents=True, exist_ok=True) + archive_path = dest_dir / name + + with tarfile.open(archive_path, "w:gz") as tar: + for src in sources: + src_path = self.root / src + if src_path.exists(): + tar.add(src_path, arcname=src) + logger.info("Backup created: %s", archive_path) + return archive_path + + def list(self, scope: str) -> List[str]: + scope_dir = self.backup_root / scope + if not scope_dir.exists(): + return [] + return sorted([item.name for item in scope_dir.iterdir() if item.is_file()]) + + def delete(self, scope: str, name: str) -> None: + target = self.backup_root / scope / name + if target.exists(): + target.unlink() diff --git a/core/backup_service.py b/core/backup_service.py new file mode 100644 index 0000000..6b88523 --- /dev/null +++ b/core/backup_service.py @@ -0,0 +1,78 @@ +import asyncio +import contextlib +from typing import Optional + +from core.logger import get_logger +from core.scheduler import ScheduleConfig, Scheduler + + +logger = get_logger("core.backup_service") + + +class BackupService: + def __init__(self, app: "OverUB") -> None: + self.app = app + cfg = app.config.get().get("backup", {}) + self._auto = bool(cfg.get("auto", False)) + schedule_time = cfg.get("schedule", "03:30") + self._scheduler = Scheduler( + ScheduleConfig( + time=schedule_time, + postpone_on_activity=bool(cfg.get("postpone_on_activity", True)), + max_downtime=int(cfg.get("max_downtime", 120)), + retry_failed=bool(cfg.get("retry_failed", True)), + retry_interval=int(cfg.get("retry_interval", 3600)), + ) + ) + self._interval = int(cfg.get("check_interval", 60)) + self._scopes = cfg.get("scopes", ["core", "modules", "plugins"]) + self._task: Optional[asyncio.Task] = None + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._run_loop()) + + async def stop(self) -> None: + if self._task: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + + async def _run_loop(self) -> None: + while True: + try: + self._refresh_config() + if self._auto and self._scheduler.should_run(self.app.last_activity): + await self._run_backup() + except Exception: + logger.exception("Scheduled backup failed") + self._scheduler.mark_failed() + self.app.update_service.record_event( + action="backup", + status="failed", + meta={"scopes": self._scopes}, + ) + await asyncio.sleep(self._interval) + + async def _run_backup(self) -> None: + for scope in self._scopes: + self.app.backups.create(scope) + self._scheduler.mark_run() + self.app.update_service.record_event( + action="backup", + status="success", + meta={"scopes": self._scopes}, + ) + + def _refresh_config(self) -> None: + cfg = self.app.config.get().get("backup", {}) + self._auto = bool(cfg.get("auto", False)) + schedule_time = cfg.get("schedule", self._scheduler.config.time) + self._scheduler.config.time = schedule_time + self._scheduler.config.postpone_on_activity = bool(cfg.get("postpone_on_activity", True)) + self._scheduler.config.max_downtime = int(cfg.get("max_downtime", 120)) + self._scheduler.config.retry_failed = bool(cfg.get("retry_failed", True)) + self._scheduler.config.retry_interval = int(cfg.get("retry_interval", 3600)) + self._interval = int(cfg.get("check_interval", self._interval)) + self._scopes = cfg.get("scopes", self._scopes) diff --git a/core/bus.py b/core/bus.py new file mode 100644 index 0000000..f20beb5 --- /dev/null +++ b/core/bus.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from core.error_handler import capture_errors + + +Subscriber = Callable[[Any], Awaitable[None]] +Requester = Callable[[Any], Awaitable[Any]] + + +@dataclass +class BusMessage: + topic: str + data: Any + + +class MessageBus: + def __init__(self) -> None: + self._topics: Dict[str, List[Subscriber]] = {} + self._services: Dict[str, Any] = {} + self._requests: Dict[str, Requester] = {} + + def subscribe(self, topic: str, handler: Subscriber) -> None: + handlers = self._topics.setdefault(topic, []) + handlers.append(capture_errors(handler)) + + async def publish(self, topic: str, data: Any) -> None: + for handler in list(self._topics.get(topic, [])): + await handler(data) + + def register_request_handler(self, topic: str, handler: Requester) -> None: + self._requests[topic] = handler + + async def request(self, topic: str, data: Any) -> Optional[Any]: + handler = self._requests.get(topic) + if not handler: + return None + return await handler(data) + + def register_service(self, name: str, service: Any) -> None: + self._services[name] = service + + def get_service(self, name: str) -> Any: + return self._services.get(name) diff --git a/core/cache.py b/core/cache.py new file mode 100644 index 0000000..ddd707a --- /dev/null +++ b/core/cache.py @@ -0,0 +1,54 @@ +import time +from collections import OrderedDict +from dataclasses import dataclass +from typing import Any, Optional + + +@dataclass +class CacheItem: + value: Any + expires_at: Optional[float] = None + + +class Cache: + def __init__(self, max_size: int = 1000) -> None: + self.max_size = max_size + self._store: "OrderedDict[str, CacheItem]" = OrderedDict() + self._hits = 0 + self._misses = 0 + + def get(self, key: str) -> Optional[Any]: + item = self._store.get(key) + if item is None: + self._misses += 1 + return None + if item.expires_at and item.expires_at <= time.time(): + self._store.pop(key, None) + self._misses += 1 + return None + self._store.move_to_end(key) + self._hits += 1 + return item.value + + def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: + expires_at = time.time() + ttl if ttl else None + self._store[key] = CacheItem(value=value, expires_at=expires_at) + self._store.move_to_end(key) + self._evict() + + def delete(self, key: str) -> None: + self._store.pop(key, None) + + def clear(self) -> None: + self._store.clear() + + def _evict(self) -> None: + while len(self._store) > self.max_size: + self._store.popitem(last=False) + + def stats(self) -> dict[str, int]: + return { + "size": len(self._store), + "hits": self._hits, + "misses": self._misses, + } diff --git a/core/changelog.py b/core/changelog.py new file mode 100644 index 0000000..d08451d --- /dev/null +++ b/core/changelog.py @@ -0,0 +1,73 @@ +from collections import defaultdict +from typing import Dict, List + + +TYPE_MAP = { + "feat": "New Features", + "fix": "Bug Fixes", + "perf": "Performance", + "refactor": "Changes", + "docs": "Documentation", + "style": "Style", + "test": "Tests", + "chore": "Maintenance", +} + +ORDER = [ + "Breaking Changes", + "New Features", + "Bug Fixes", + "Performance", + "Changes", + "Documentation", + "Style", + "Tests", + "Maintenance", + "Other", +] + + +def parse_conventional(commits: List[str]) -> Dict[str, List[str]]: + buckets: Dict[str, List[str]] = defaultdict(list) + for line in commits: + parts = line.split(" ", 1) + if len(parts) != 2: + continue + message = parts[1].strip() + header = message + subject = "" + if ":" in message: + header, subject = message.split(":", 1) + header = header.strip() + subject = subject.strip() or message + breaking = False + if "!" in header: + header = header.split("!", 1)[0] + breaking = True + base_type = header.split("(", 1)[0].strip() + category = TYPE_MAP.get(base_type, "Other") + buckets[category].append(subject) + if "BREAKING" in message.upper(): + breaking = True + if breaking: + buckets["Breaking Changes"].append(subject) + return buckets + + +def format_changelog(buckets: Dict[str, List[str]], inline: bool = False) -> str: + if inline: + parts = [] + for key in ORDER: + items = buckets.get(key, []) + if items: + parts.append(f"{key}: {len(items)}") + return " | ".join(parts) if parts else "No changes" + lines = [] + for key in ORDER: + items = buckets.get(key, []) + if not items: + continue + lines.append(key) + for item in items: + lines.append(f"- {item}") + return "\n".join(lines) if lines else "No changelog entries" diff --git a/core/cli.py b/core/cli.py new file mode 100644 index 0000000..f0b7e84 --- /dev/null +++ b/core/cli.py @@ -0,0 +1,167 @@ +import argparse +import subprocess +import shutil +from pathlib import Path + +from core.logger import setup_logging + + +def create_plugin(args: argparse.Namespace) -> None: + root = Path(args.root).resolve() + plugin_path = root / "plugins" / "external" / args.name + plugin_path.mkdir(parents=True, exist_ok=True) + (plugin_path / "__init__.py").write_text( + "from .plugin import *\n", + encoding="utf-8", + ) + (plugin_path / "plugin.py").write_text( + f"from core.plugin import Plugin\n\n\nclass {args.class_name}(Plugin):\n name = \"{args.name}\"\n version = \"0.1.0\"\n author = \"{args.author}\"\n description = \"{args.description}\"\n\n async def on_load(self):\n self.log.info(\"{args.name} loaded\")\n", + encoding="utf-8", + ) + (plugin_path / "config.yml").write_text( + f"{args.name}:\n enabled: true\n settings: {{}}\n", + encoding="utf-8", + ) + (plugin_path / "requirements.txt").write_text("", encoding="utf-8") + (plugin_path / "README.md").write_text( + f"# {args.name}\n\n{args.description}\n", + encoding="utf-8", + ) + print(f"Created plugin at {plugin_path}") + + +def validate_plugin(args: argparse.Namespace) -> None: + path = Path(args.path).resolve() + required = ["__init__.py", "plugin.py"] + missing = [item for item in required if not (path / item).exists()] + if missing: + print(f"Missing files: {', '.join(missing)}") + raise SystemExit(1) + print("Plugin structure OK") + + +def build_plugin(args: argparse.Namespace) -> None: + path = Path(args.path).resolve() + output = Path(args.output).resolve() + shutil.make_archive(str(output), "zip", root_dir=path) + print(f"Built {output}.zip") + + +def publish_plugin(args: argparse.Namespace) -> None: + root = Path(args.root).resolve() + if not args.name or not args.version: + print("Publish requires --name and --version") + raise SystemExit(1) + plugin_path = Path(args.path).resolve() if args.path else root / "plugins" / "external" / args.name + if not plugin_path.exists(): + print(f"Plugin path not found: {plugin_path}") + raise SystemExit(1) + tag = args.version if args.version.startswith("v") else f"v{args.version}" + try: + subprocess.run(["git", "status"], cwd=plugin_path, check=True, capture_output=True, text=True) + except subprocess.CalledProcessError as exc: + print(exc.stderr.strip() or "Git status failed") + raise SystemExit(1) from exc + try: + subprocess.run(["git", "tag", "-a", tag, "-m", f"Release {tag}"], cwd=plugin_path, check=True) + subprocess.run(["git", "push", "origin", tag], cwd=plugin_path, check=True) + except subprocess.CalledProcessError as exc: + print(exc.stderr.strip() or "Git tag/push failed") + raise SystemExit(1) from exc + try: + log = subprocess.run( + ["git", "log", "-5", "--pretty=format:- %s"], + cwd=plugin_path, + check=True, + capture_output=True, + text=True, + ) + note = log.stdout.strip() or f"Release {tag}" + subprocess.run( + [ + "tea", + "releases", + "create", + "--tag", + tag, + "--title", + f"{args.name} {tag}", + "--note", + note, + ], + cwd=plugin_path, + check=True, + ) + except subprocess.CalledProcessError as exc: + print(exc.stderr.strip() or "Tea release creation failed") + raise SystemExit(1) from exc + print(f"Published {args.name} {tag}") + + +def test_plugin(args: argparse.Namespace) -> None: + import py_compile + + path = Path(args.path).resolve() + errors = [] + for file in path.rglob("*.py"): + try: + py_compile.compile(str(file), doraise=True) + except Exception as exc: + errors.append((file, exc)) + if errors: + for file, exc in errors: + print(f"{file}: {exc}") + raise SystemExit(1) + print("Plugin tests OK") + + +def generate_docs(args: argparse.Namespace) -> None: + path = Path(args.path).resolve() + (path / "README.md").write_text("# Plugin\n", encoding="utf-8") + (path / "COMMANDS.md").write_text("# Commands\n", encoding="utf-8") + (path / "CONFIG.md").write_text("# Config\n", encoding="utf-8") + (path / "API.md").write_text("# API\n", encoding="utf-8") + print("Docs generated") + + +def run_cli() -> None: + parser = argparse.ArgumentParser(prog="overub") + parser.add_argument("--root", default=Path(__file__).resolve().parents[1]) + sub = parser.add_subparsers(dest="command") + + create = sub.add_parser("create-plugin") + create.add_argument("name") + create.add_argument("--class-name", default="MyPlugin") + create.add_argument("--author", default="overub") + create.add_argument("--description", default="OverUB plugin") + create.set_defaults(func=create_plugin) + + validate = sub.add_parser("validate-plugin") + validate.add_argument("path") + validate.set_defaults(func=validate_plugin) + + build = sub.add_parser("build-plugin") + build.add_argument("path") + build.add_argument("--output", default="overub-plugin") + build.set_defaults(func=build_plugin) + + test = sub.add_parser("test-plugin") + test.add_argument("path") + test.set_defaults(func=test_plugin) + + docs = sub.add_parser("docs-plugin") + docs.add_argument("path") + docs.set_defaults(func=generate_docs) + + publish = sub.add_parser("publish-plugin") + publish.add_argument("--name", default="") + publish.add_argument("--version", default="") + publish.add_argument("--path", default="") + publish.set_defaults(func=publish_plugin) + + args = parser.parse_args() + setup_logging("INFO") + if not hasattr(args, "func"): + parser.print_help() + return + args.func(args) diff --git a/core/client.py b/core/client.py new file mode 100644 index 0000000..c92990e --- /dev/null +++ b/core/client.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Any + +from core.logger import get_logger + + +logger = get_logger("core.client") + + +class ClientWrapper: + def __init__(self, api_id: int, api_hash: str, session_name: str) -> None: + self.api_id = api_id + self.api_hash = api_hash + self.session_name = session_name + self.client: Any = None + + async def connect(self) -> None: + try: + from telethon import TelegramClient + except ImportError as exc: + raise RuntimeError("Telethon not installed") from exc + + self.client = TelegramClient(self.session_name, self.api_id, self.api_hash) + await self.client.start() + logger.info("Telethon client connected") + + async def attach_handlers(self, app: Any) -> None: + if self.client is None: + return + try: + from telethon import events + except ImportError as exc: + raise RuntimeError("Telethon not installed") from exc + + @self.client.on(events.NewMessage) + async def on_new_message(event): + await app.handle_message_event(event) + + @self.client.on(events.MessageEdited) + async def on_message_edit(event): + await app.handle_edit_event(event) + + @self.client.on(events.MessageDeleted) + async def on_message_delete(event): + await app.handle_delete_event(event) + + if hasattr(events, "MessageRead"): + @self.client.on(events.MessageRead) + async def on_message_read(event): + await app.events.emit("on_message_read", event=event) + + @self.client.on(events.ChatAction) + async def on_chat_action(event): + await app.events.emit("on_chat_action", event=event) + await app.events.emit("on_chat_update", event=event) + if getattr(event, "user_typing", False): + await app.events.emit("on_typing", event=event) + if getattr(event, "user_recording", False): + await app.events.emit("on_recording", event=event) + + if hasattr(events, "CallbackQuery"): + @self.client.on(events.CallbackQuery) + async def on_callback_query(event): + await app.events.emit("on_callback_query", event=event) + + if hasattr(events, "InlineQuery"): + @self.client.on(events.InlineQuery) + async def on_inline_query(event): + await app.events.emit("on_inline_query", event=event) + + if hasattr(events, "UserUpdate"): + @self.client.on(events.UserUpdate) + async def on_user_update(event): + await app.events.emit("on_user_update", event=event) + if getattr(event, "status", None) is not None: + await app.events.emit("on_status_update", event=event) + if getattr(event, "contact", None) is not None: + await app.events.emit("on_contact_update", event=event) + + if hasattr(events, "Disconnected"): + @self.client.on(events.Disconnected) + async def on_disconnect(event): + await app.events.emit("on_disconnect", event=event) + + if hasattr(events, "Connected"): + @self.client.on(events.Connected) + async def on_reconnect(event): + await app.events.emit("on_reconnect", event=event) + + logger.info("Telethon event handlers attached") + + async def disconnect(self) -> None: + if self.client: + await self.client.disconnect() + logger.info("Telethon client disconnected") + + async def wait_until_disconnected(self) -> None: + if self.client: + await self.client.disconnected diff --git a/core/commands.py b/core/commands.py new file mode 100644 index 0000000..1e37d20 --- /dev/null +++ b/core/commands.py @@ -0,0 +1,312 @@ +import shlex +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple + +from core.error_handler import capture_errors + + +CommandHandler = Callable[..., Awaitable[None]] + + +@dataclass +class Command: + name: str + handler: CommandHandler + description: str = "" + aliases: List[str] = field(default_factory=list) + category: str = "" + usage: str = "" + example: str = "" + cooldown: int = 0 + permission: str = "user" + chat_types: List[str] = field(default_factory=list) + owner: str = "" + owner_type: str = "" + prefix: Optional[str] = None + arguments: List["ArgumentSpec"] = field(default_factory=list) + flags: List["FlagSpec"] = field(default_factory=list) + subcommands: Dict[str, "Command"] = field(default_factory=dict) + + +@dataclass +class ArgumentSpec: + name: str + arg_type: Callable[[str], Any] = str + required: bool = False + default: Any = None + variadic: bool = False + + +@dataclass +class FlagSpec: + name: str + flag_type: Callable[[str], Any] = str + default: Any = None + aliases: List[str] = field(default_factory=list) + + +@dataclass +class ParsedCommand: + name: str + args: List[str] + flags: Dict[str, Any] + raw: str + prefix: str + + +class CommandRegistry: + def __init__(self, prefix: str = ".") -> None: + self.prefix = prefix + self._commands: Dict[str, Command] = {} + self._cooldowns: Dict[Tuple[str, int], float] = {} + self._prefixes: List[str] = [prefix] + + def register(self, command: Command) -> None: + self._commands[command.name] = command + for alias in command.aliases: + self._commands[alias] = command + if command.prefix and command.prefix not in self._prefixes: + self._prefixes.append(command.prefix) + + def get(self, name: str) -> Optional[Command]: + return self._commands.get(name) + + def resolve(self, name: str, args: List[str]) -> Tuple[Optional[Command], List[str]]: + command = self._commands.get(name) + if command and args and command.subcommands: + sub = command.subcommands.get(args[0]) + if sub: + return sub, args[1:] + return command, args + + def register_subcommand(self, parent: str, command: Command) -> None: + base = self._commands.get(parent) + if base: + base.subcommands[command.name] = command + + def list(self) -> List[Command]: + seen = set() + result = [] + for cmd in self._commands.values(): + if cmd.name in seen: + continue + seen.add(cmd.name) + result.append(cmd) + return result + + def parse(self, text: str) -> Optional[ParsedCommand]: + prefix = self._match_prefix(text) + if not prefix: + return None + raw = text[len(prefix):].strip() + if not raw: + return None + parts = shlex.split(raw) + if not parts: + return None + name = parts[0] + args, flags = self._parse_flags(parts[1:]) + return ParsedCommand(name=name, args=args, flags=flags, raw=raw, prefix=prefix) + + def cooldown_remaining(self, command: Command, user_id: int) -> int: + if command.cooldown <= 0: + return 0 + key = (command.name, user_id) + last = self._cooldowns.get(key, 0.0) + now = time.time() + remaining = command.cooldown - (now - last) + if remaining > 0: + return int(remaining) + 1 + self._cooldowns[key] = now + return 0 + + def help_text(self, name: Optional[str] = None) -> str: + if name: + command = self._commands.get(name) + if not command: + return "Command not found" + return self._format_command(command) + lines = ["Available commands:"] + for command in sorted(self.list(), key=lambda cmd: cmd.name): + lines.append(f"{self.prefix}{command.name} - {command.description}") + return "\n".join(lines) + + def _format_command(self, command: Command) -> str: + parts = [ + f"Name: {command.name}", + f"Description: {command.description}", + f"Usage: {command.usage or self.prefix + command.name}", + ] + if command.aliases: + parts.append(f"Aliases: {', '.join(command.aliases)}") + if command.example: + parts.append(f"Example: {command.example}") + return "\n".join(parts) + + def _match_prefix(self, text: str) -> Optional[str]: + for prefix in sorted(self._prefixes, key=len, reverse=True): + if text.startswith(prefix): + return prefix + return None + + def _parse_flags(self, tokens: List[str]) -> Tuple[List[str], Dict[str, Any]]: + args: List[str] = [] + flags: Dict[str, Any] = {} + idx = 0 + while idx < len(tokens): + token = tokens[idx] + if token.startswith("--"): + if "=" in token: + key, value = token[2:].split("=", 1) + flags[key] = value + idx += 1 + continue + key = token[2:] + if idx + 1 < len(tokens) and not tokens[idx + 1].startswith("-"): + flags[key] = tokens[idx + 1] + idx += 2 + continue + flags[key] = True + idx += 1 + continue + if token.startswith("-") and len(token) > 1: + for flag in token[1:]: + flags[flag] = True + idx += 1 + continue + args.append(token) + idx += 1 + return args, flags + + +class CommandBuilder: + def __init__(self, registry: CommandRegistry, owner: str = "", owner_type: str = "", prefix: Optional[str] = None) -> None: + self.registry = registry + self.owner = owner + self.owner_type = owner_type + self.prefix = prefix + + def with_owner(self, owner: str, owner_type: str = "", prefix: Optional[str] = None) -> "CommandBuilder": + return CommandBuilder(self.registry, owner=owner, owner_type=owner_type, prefix=prefix) + + def command( + self, + name: str, + description: str = "", + aliases: Optional[List[str]] = None, + category: str = "", + usage: str = "", + example: str = "", + cooldown: int = 0, + permission: str = "user", + chat_types: Optional[List[str]] = None, + owner: Optional[str] = None, + owner_type: Optional[str] = None, + prefix: Optional[str] = None, + arguments: Optional[List[ArgumentSpec]] = None, + flags: Optional[List[FlagSpec]] = None, + ) -> Callable[[CommandHandler], CommandHandler]: + aliases = aliases or [] + chat_types = chat_types or [] + + def decorator(handler: CommandHandler) -> CommandHandler: + command = Command( + name=name, + handler=capture_errors(handler), + description=description, + aliases=aliases, + category=category, + usage=usage, + example=example, + cooldown=cooldown, + permission=permission, + chat_types=chat_types, + owner=owner or self.owner, + owner_type=owner_type or self.owner_type, + prefix=prefix or self.prefix, + arguments=arguments or [], + flags=flags or [], + ) + self.registry.register(command) + return handler + + return decorator + + +def command(name: str, **meta: Any) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + setattr(handler, "_command_meta", {"name": name, **meta}) + return handler + return decorator + + +def alias(*names: str) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta.setdefault("aliases", []) + meta["aliases"].extend(names) + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def category(name: str) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta["category"] = name + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def usage(text: str) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta["usage"] = text + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def example(text: str) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta["example"] = text + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def cooldown(seconds: int) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta["cooldown"] = seconds + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def permission(level: str) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta["permission"] = level + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def chat_type(*types: str) -> Callable[[CommandHandler], CommandHandler]: + def decorator(handler: CommandHandler) -> CommandHandler: + meta = getattr(handler, "_command_meta", {}) + meta["chat_types"] = list(types) + setattr(handler, "_command_meta", meta) + return handler + return decorator + + +def register_decorated(builder: CommandBuilder, handler: CommandHandler) -> None: + meta = getattr(handler, "_command_meta", None) + if not meta: + return + builder.command(**meta)(handler) diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..2e21f72 --- /dev/null +++ b/core/config.py @@ -0,0 +1,213 @@ +import os +from pathlib import Path +from typing import Any, Dict, List + +import yaml + + +def _deep_update(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: + for key, value in updates.items(): + if isinstance(value, dict) and isinstance(base.get(key), dict): + base[key] = _deep_update(base[key], value) + else: + base[key] = value + return base + + +class ConfigManager: + def __init__(self, config_path: Path, modules_path: Path): + self.config_path = config_path + self.modules_path = modules_path + self._config: Dict[str, Any] = {} + self._modules: Dict[str, Any] = {} + self._plugin_schemas: Dict[str, Dict[str, Any]] = {} + + def load(self) -> None: + self._config = self._read_yaml(self.config_path) + self._modules = self._read_yaml(self.modules_path) + self._config.setdefault("config_version", "1.0.0") + self._apply_env_overrides() + + def _read_yaml(self, path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + return yaml.safe_load(path.read_text(encoding="utf-8")) or {} + + def _apply_env_overrides(self) -> None: + env_map = { + "OVERUB_API_ID": ("bot", "api_id"), + "OVERUB_API_HASH": ("bot", "api_hash"), + "OVERUB_SESSION": ("bot", "session_name"), + "OVERUB_PREFIX": ("bot", "command_prefix"), + "OVERUB_LOG_LEVEL": ("logging", "level"), + "OVERUB_GIT_REMOTE": ("updates", "git", "remote"), + "OVERUB_GITEA_TOKEN": ("updates", "git", "token"), + "OVERUB_GITEA_API": ("updates", "gitea", "api_url"), + "OVERUB_GIT_BRANCH": ("updates", "branch"), + "OVERUB_WEBHOOK_SECRET": ("updates", "gitea", "webhook_secret"), + } + for env_key, path in env_map.items(): + value = os.getenv(env_key) + if value is None: + continue + self._set_path(self._config, path, value) + self._apply_plugin_env_overrides() + + def _apply_plugin_env_overrides(self) -> None: + prefix = "OVERUB_PLUGIN_" + for key, value in os.environ.items(): + if not key.startswith(prefix): + continue + parts = key[len(prefix):].split("_") + if len(parts) < 2: + continue + plugin = parts[0].lower() + scope = parts[1].lower() + if scope == "enabled": + cfg = self.get_plugin_config(plugin) + cfg["enabled"] = value.lower() == "true" + self.set_plugin_config(plugin, cfg) + elif scope == "setting" and len(parts) >= 3: + setting_key = "_".join(parts[2:]).lower() + cfg = self.get_plugin_config(plugin) + cfg.setdefault("settings", {})[setting_key] = value + self.set_plugin_config(plugin, cfg) + + def _set_path(self, data: Dict[str, Any], path: tuple, value: Any) -> None: + cursor = data + for key in path[:-1]: + cursor = cursor.setdefault(key, {}) + cursor[path[-1]] = value + + def get(self) -> Dict[str, Any]: + return self._config + + def get_modules(self) -> Dict[str, Any]: + return self._modules + + def get_module_config(self, name: str) -> Dict[str, Any]: + return self._modules.get("modules", {}).get(name, {}) + + def get_plugin_config(self, name: str) -> Dict[str, Any]: + plugins = self._config.setdefault("plugin_settings", {}) + if name not in plugins: + plugins[name] = { + "enabled": True, + "settings": {}, + "secrets": {}, + "permissions": [], + "command_prefix": None, + "cooldown": 0, + "timeout": None, + "permission_level": "user", + "auto_update": True, + "max_memory_mb": None, + "max_cpu_percent": None, + } + return plugins[name] + + def set_plugin_config(self, name: str, data: Dict[str, Any]) -> None: + plugins = self._config.setdefault("plugin_settings", {}) + plugins[name] = data + + def register_plugin_schema(self, name: str, schema: Dict[str, Any]) -> None: + self._plugin_schemas[name] = schema + + def validate_plugin_config(self, name: str) -> List[str]: + schema = self._plugin_schemas.get(name) + if not schema: + return [] + cfg = self.get_plugin_config(name) + errors = [] + for key, expected_type in schema.items(): + if key not in cfg: + errors.append(f"Missing key: {key}") + continue + value = cfg[key] + if expected_type and not isinstance(value, expected_type): + errors.append(f"Invalid type for {key}") + return errors + + def migrate_plugin_config(self, name: str, new_config: Dict[str, Any]) -> None: + current = self.get_plugin_config(name) + current.update(new_config) + self.set_plugin_config(name, current) + + def reload(self) -> None: + self.load() + + def migrate(self, target_version: str) -> None: + self._config["config_version"] = target_version + + def encrypt_value(self, value: str) -> str: + key = self._config.get("security", {}).get("secret_key") + if not key: + return value + try: + from cryptography.fernet import Fernet + except ImportError: + return value + fernet = Fernet(key.encode("utf-8")) + token = fernet.encrypt(value.encode("utf-8")) + return f"ENC:{token.decode('utf-8')}" + + def decrypt_value(self, value: str) -> str: + if not isinstance(value, str) or not value.startswith("ENC:"): + return value + key = self._config.get("security", {}).get("secret_key") + if not key: + return value + try: + from cryptography.fernet import Fernet + except ImportError: + return value + fernet = Fernet(key.encode("utf-8")) + token = value[4:].encode("utf-8") + return fernet.decrypt(token).decode("utf-8") + + def merge(self, updates: Dict[str, Any]) -> None: + _deep_update(self._config, updates) + + def save(self) -> None: + self.config_path.write_text( + yaml.safe_dump(self._config, sort_keys=False), + encoding="utf-8", + ) + + def save_modules(self) -> None: + self.modules_path.write_text( + yaml.safe_dump(self._modules, sort_keys=False), + encoding="utf-8", + ) + + +class PluginConfigProxy: + def __init__(self, manager: ConfigManager, plugin: str) -> None: + self._manager = manager + self._plugin = plugin + + def get_plugin_config(self, name: str) -> Dict[str, Any]: + if name != self._plugin: + raise PermissionError("Access denied") + return self._manager.get_plugin_config(name) + + def set_plugin_config(self, name: str, data: Dict[str, Any]) -> None: + if name != self._plugin: + raise PermissionError("Access denied") + self._manager.set_plugin_config(name, data) + + def encrypt_value(self, value: str) -> str: + return self._manager.encrypt_value(value) + + def decrypt_value(self, value: str) -> str: + return self._manager.decrypt_value(value) + + def register_plugin_schema(self, name: str, schema: Dict[str, Any]) -> None: + if name != self._plugin: + raise PermissionError("Access denied") + self._manager.register_plugin_schema(name, schema) + + def validate_plugin_config(self, name: str) -> List[str]: + if name != self._plugin: + raise PermissionError("Access denied") + return self._manager.validate_plugin_config(name) diff --git a/core/config_ui.py b/core/config_ui.py new file mode 100644 index 0000000..f9636cd --- /dev/null +++ b/core/config_ui.py @@ -0,0 +1,17 @@ +from typing import Dict + + +def render_plugin_config(name: str, cfg: Dict[str, object]) -> str: + lines = [f"Plugin: {name}"] + lines.append(f"Enabled: {cfg.get('enabled', True)}") + lines.append(f"Cooldown: {cfg.get('cooldown', 0)}") + lines.append(f"Auto-update: {cfg.get('auto_update', True)}") + prefix = cfg.get("command_prefix") + if prefix: + lines.append(f"Prefix: {prefix}") + settings = cfg.get("settings", {}) + if settings: + lines.append("Settings:") + for key, value in settings.items(): + lines.append(f"- {key}: {value}") + return "\n".join(lines) diff --git a/core/database.py b/core/database.py new file mode 100644 index 0000000..38929e0 --- /dev/null +++ b/core/database.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any, Dict, Iterable, Optional + +from core.logger import get_logger + + +logger = get_logger("core.database") + + +class DatabaseManager: + def __init__(self, db_type: str, options: Dict[str, Any], db_path: Path) -> None: + self.db_type = db_type + self.options = options + self.db_path = db_path + self._conn = None + self._pg_pool = None + self._mongo = None + self._redis = None + + async def connect(self) -> None: + if self.db_type == "sqlite": + import aiosqlite + + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._conn = await aiosqlite.connect(self.db_path) + await self._conn.execute("PRAGMA journal_mode=WAL") + return + if self.db_type == "postgres": + try: + import asyncpg + except ImportError as exc: + raise RuntimeError("asyncpg not installed") from exc + dsn = self.options.get("dsn") + self._pg_pool = await asyncpg.create_pool(dsn=dsn) + return + if self.db_type == "mongodb": + try: + import motor.motor_asyncio + except ImportError as exc: + raise RuntimeError("motor not installed") from exc + uri = self.options.get("uri", "mongodb://localhost:27017") + database = self.options.get("database", "overub") + client = motor.motor_asyncio.AsyncIOMotorClient(uri) + self._mongo = client[database] + return + if self.db_type == "redis": + try: + import redis.asyncio as aioredis + except ImportError as exc: + raise RuntimeError("redis not installed") from exc + url = self.options.get("url", "redis://localhost:6379/0") + self._redis = aioredis.from_url(url) + return + raise RuntimeError("Unsupported database type") + + async def close(self) -> None: + if self.db_type == "sqlite" and self._conn is not None: + await self._conn.close() + if self.db_type == "postgres" and self._pg_pool is not None: + await self._pg_pool.close() + if self.db_type == "redis" and self._redis is not None: + await self._redis.close() + + async def execute(self, query: str, params: Iterable[Any] = ()) -> None: + if self.db_type == "sqlite": + if self._conn is None: + raise RuntimeError("Database not connected") + await self._conn.execute(query, params) + await self._conn.commit() + return + if self.db_type == "postgres": + if self._pg_pool is None: + raise RuntimeError("Database not connected") + query = self._convert_query(query, params) + async with self._pg_pool.acquire() as conn: + await conn.execute(query, *params) + return + raise RuntimeError("Execute not supported for this backend") + + async def fetchone(self, query: str, params: Iterable[Any] = ()) -> Optional[Dict[str, Any]]: + if self.db_type == "sqlite": + if self._conn is None: + raise RuntimeError("Database not connected") + self._conn.row_factory = __import__("aiosqlite").Row + cursor = await self._conn.execute(query, params) + row = await cursor.fetchone() + return dict(row) if row else None + if self.db_type == "postgres": + if self._pg_pool is None: + raise RuntimeError("Database not connected") + query = self._convert_query(query, params) + async with self._pg_pool.acquire() as conn: + row = await conn.fetchrow(query, *params) + return dict(row) if row else None + raise RuntimeError("Fetch not supported for this backend") + + async def fetchall(self, query: str, params: Iterable[Any] = ()) -> list[Dict[str, Any]]: + if self.db_type == "sqlite": + if self._conn is None: + raise RuntimeError("Database not connected") + self._conn.row_factory = __import__("aiosqlite").Row + cursor = await self._conn.execute(query, params) + rows = await cursor.fetchall() + return [dict(row) for row in rows] + if self.db_type == "postgres": + if self._pg_pool is None: + raise RuntimeError("Database not connected") + query = self._convert_query(query, params) + async with self._pg_pool.acquire() as conn: + rows = await conn.fetch(query, *params) + return [dict(row) for row in rows] + raise RuntimeError("Fetch not supported for this backend") + + async def ensure_plugin_kv(self) -> None: + if self.db_type == "sqlite": + await self.execute( + """ + CREATE TABLE IF NOT EXISTS plugin_kv ( + plugin TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + expires_at INTEGER, + PRIMARY KEY (plugin, key) + ) + """ + ) + return + if self.db_type == "postgres": + await self.execute( + """ + CREATE TABLE IF NOT EXISTS plugin_kv ( + plugin TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT, + expires_at BIGINT, + PRIMARY KEY (plugin, key) + ) + """ + ) + + def plugin_db(self, name: str) -> "PluginDatabase": + return PluginDatabase(self, name) + + async def kv_get(self, plugin: str, key: str) -> Optional[Any]: + if self.db_type in {"sqlite", "postgres"}: + await self.ensure_plugin_kv() + row = await self.fetchone( + "SELECT value, expires_at FROM plugin_kv WHERE plugin=? AND key=?", + (plugin, key), + ) + if not row: + return None + expires_at = row.get("expires_at") + if expires_at and expires_at <= int(time.time()): + await self.kv_delete(plugin, key) + return None + return json.loads(row.get("value") or "null") + if self.db_type == "mongodb": + doc = await self._mongo.plugin_kv.find_one({"plugin": plugin, "key": key}) + if not doc: + return None + expires_at = doc.get("expires_at") + if expires_at and expires_at <= int(time.time()): + await self.kv_delete(plugin, key) + return None + return json.loads(doc.get("value") or "null") + if self.db_type == "redis": + value = await self._redis.get(f"plugin:{plugin}:{key}") + if value is None: + return None + return json.loads(value) + return None + + async def kv_set(self, plugin: str, key: str, value: Any) -> None: + payload = json.dumps(value) + if self.db_type in {"sqlite", "postgres"}: + await self.ensure_plugin_kv() + if self.db_type == "postgres": + await self.execute( + "INSERT INTO plugin_kv (plugin, key, value, expires_at) VALUES (?, ?, ?, NULL) " + "ON CONFLICT (plugin, key) DO UPDATE SET value=EXCLUDED.value, expires_at=NULL", + (plugin, key, payload), + ) + else: + await self.execute( + "INSERT OR REPLACE INTO plugin_kv (plugin, key, value, expires_at) VALUES (?, ?, ?, NULL)", + (plugin, key, payload), + ) + return + if self.db_type == "mongodb": + await self._mongo.plugin_kv.update_one( + {"plugin": plugin, "key": key}, + {"$set": {"value": payload, "expires_at": None}}, + upsert=True, + ) + return + if self.db_type == "redis": + await self._redis.set(f"plugin:{plugin}:{key}", payload) + + async def kv_delete(self, plugin: str, key: str) -> None: + if self.db_type in {"sqlite", "postgres"}: + await self.ensure_plugin_kv() + await self.execute( + "DELETE FROM plugin_kv WHERE plugin=? AND key=?", + (plugin, key), + ) + return + if self.db_type == "mongodb": + await self._mongo.plugin_kv.delete_one({"plugin": plugin, "key": key}) + return + if self.db_type == "redis": + await self._redis.delete(f"plugin:{plugin}:{key}") + + async def kv_list(self, plugin: str, pattern: str = "%") -> list[str]: + if self.db_type in {"sqlite", "postgres"}: + await self.ensure_plugin_kv() + rows = await self.fetchall( + "SELECT key FROM plugin_kv WHERE plugin=? AND key LIKE ?", + (plugin, pattern), + ) + return [row["key"] for row in rows] + if self.db_type == "mongodb": + cursor = self._mongo.plugin_kv.find({"plugin": plugin}) + return [doc["key"] async for doc in cursor] + if self.db_type == "redis": + keys = await self._redis.keys(f"plugin:{plugin}:*") + return [key.decode("utf-8").split(":", 2)[2] for key in keys] + return [] + + async def kv_exists(self, plugin: str, key: str) -> bool: + if self.db_type in {"sqlite", "postgres"}: + await self.ensure_plugin_kv() + row = await self.fetchone( + "SELECT 1 FROM plugin_kv WHERE plugin=? AND key=?", + (plugin, key), + ) + return row is not None + if self.db_type == "mongodb": + doc = await self._mongo.plugin_kv.find_one({"plugin": plugin, "key": key}) + return doc is not None + if self.db_type == "redis": + return bool(await self._redis.exists(f"plugin:{plugin}:{key}")) + return False + + async def kv_expire(self, plugin: str, key: str, seconds: int) -> None: + if self.db_type in {"sqlite", "postgres"}: + await self.ensure_plugin_kv() + expires_at = int(time.time()) + seconds + await self.execute( + "UPDATE plugin_kv SET expires_at=? WHERE plugin=? AND key=?", + (expires_at, plugin, key), + ) + return + if self.db_type == "mongodb": + expires_at = int(time.time()) + seconds + await self._mongo.plugin_kv.update_one( + {"plugin": plugin, "key": key}, + {"$set": {"expires_at": expires_at}}, + ) + return + if self.db_type == "redis": + await self._redis.expire(f"plugin:{plugin}:{key}", seconds) + + def _convert_query(self, query: str, params: Iterable[Any]) -> str: + if self.db_type != "postgres": + return query + if "?" not in query: + return query + converted = [] + index = 1 + for char in query: + if char == "?": + converted.append(f"${index}") + index += 1 + else: + converted.append(char) + return "".join(converted) + + +class PluginDatabase: + def __init__(self, manager: DatabaseManager, plugin: str) -> None: + self.manager = manager + self.plugin = plugin + + async def get(self, key: str) -> Optional[Any]: + return await self.manager.kv_get(self.plugin, key) + + async def set(self, key: str, value: Any) -> None: + await self.manager.kv_set(self.plugin, key, value) + + async def delete(self, key: str) -> None: + await self.manager.kv_delete(self.plugin, key) + + async def list(self, pattern: str = "%") -> list[str]: + return await self.manager.kv_list(self.plugin, pattern) + + async def exists(self, key: str) -> bool: + return await self.manager.kv_exists(self.plugin, key) + + async def expire(self, key: str, seconds: int) -> None: + await self.manager.kv_expire(self.plugin, key, seconds) + + async def query(self, sql: str) -> list[Dict[str, Any]]: + return await self.manager.fetchall(sql) diff --git a/core/error_handler.py b/core/error_handler.py new file mode 100644 index 0000000..24d077e --- /dev/null +++ b/core/error_handler.py @@ -0,0 +1,18 @@ +import asyncio +from typing import Awaitable, Callable + +from core.logger import get_logger + + +logger = get_logger("core.error_handler") + + +def capture_errors(handler: Callable[..., Awaitable[None]]) -> Callable[..., Awaitable[None]]: + async def wrapper(*args, **kwargs) -> None: + try: + await handler(*args, **kwargs) + except asyncio.CancelledError: + raise + except Exception: + logger.exception("Unhandled exception in handler") + return wrapper diff --git a/core/events.py b/core/events.py new file mode 100644 index 0000000..a169a73 --- /dev/null +++ b/core/events.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Dict, List, Tuple + +from core.error_handler import capture_errors +from core.logger import get_logger + + +logger = get_logger("core.events") +EventHandler = Callable[["Event"], Awaitable[None]] + + +@dataclass +class Event: + name: str + payload: Dict[str, Any] = field(default_factory=dict) + cancelled: bool = False + + def cancel(self) -> None: + self.cancelled = True + + +class EventDispatcher: + def __init__(self) -> None: + self._handlers: Dict[str, List[Tuple[int, EventHandler]]] = {} + + def on(self, event_name: str, handler: EventHandler, priority: int = 50) -> None: + handlers = self._handlers.setdefault(event_name, []) + handlers.append((priority, capture_errors(handler))) + handlers.sort(key=lambda item: item[0]) + + def off(self, event_name: str, handler: EventHandler) -> None: + if event_name not in self._handlers: + return + self._handlers[event_name] = [ + item for item in self._handlers[event_name] if item[1] != handler + ] + + async def emit(self, event_name: str, **payload: Any) -> Event: + event = Event(name=event_name, payload=payload) + handlers = list(self._handlers.get(event_name, [])) + for _, handler in handlers: + await handler(event) + if event.cancelled: + logger.debug("Event %s cancelled", event_name) + break + return event diff --git a/core/gitea.py b/core/gitea.py new file mode 100644 index 0000000..4b892c7 --- /dev/null +++ b/core/gitea.py @@ -0,0 +1,33 @@ +import json +from typing import Any, Dict, List, Optional +from urllib import parse, request + + +class GiteaClient: + def __init__(self, api_url: str, token: str = "") -> None: + self.api_url = api_url.rstrip("/") + self.token = token + + def _headers(self) -> Dict[str, str]: + headers = {"Accept": "application/json"} + if self.token: + headers["Authorization"] = f"token {self.token}" + return headers + + def _get(self, path: str, params: Optional[Dict[str, str]] = None) -> Any: + url = f"{self.api_url}{path}" + if params: + url = f"{url}?{parse.urlencode(params)}" + req = request.Request(url, headers=self._headers()) + with request.urlopen(req) as response: + payload = response.read().decode("utf-8") + return json.loads(payload) + + def search_repos(self, query: str) -> List[Dict[str, Any]]: + return self._get("/repos/search", {"q": query}).get("data", []) + + def repo_info(self, owner: str, repo: str) -> Dict[str, Any]: + return self._get(f"/repos/{owner}/{repo}") + + def releases(self, owner: str, repo: str) -> List[Dict[str, Any]]: + return self._get(f"/repos/{owner}/{repo}/releases") diff --git a/core/http.py b/core/http.py new file mode 100644 index 0000000..39e0d3e --- /dev/null +++ b/core/http.py @@ -0,0 +1,66 @@ +import json +from typing import Optional, Any + +from core.logger import get_logger + + +logger = get_logger("core.http") + + +class SessionManager: + def __init__(self) -> None: + self._session: Optional[object] = None + + async def get_session(self) -> Optional[object]: + if self._session: + return self._session + try: + import aiohttp + except ImportError: + logger.warning("aiohttp not installed") + return None + self._session = aiohttp.ClientSession() + return self._session + + async def close(self) -> None: + if self._session: + await self._session.close() + self._session = None + + async def get_json(self, url: str) -> Optional[Any]: + session = await self.get_session() + if session: + async with session.get(url) as response: + return await response.json() + try: + from urllib import request + except Exception: + return None + loop = __import__("asyncio").get_event_loop() + return await loop.run_in_executor(None, self._sync_get_json, url) + + def _sync_get_json(self, url: str) -> Optional[Any]: + from urllib import request + + with request.urlopen(url) as response: + payload = response.read().decode("utf-8") + try: + return json.loads(payload) + except json.JSONDecodeError: + return None + + +class RestrictedSession: + def __init__(self, manager: SessionManager, allow_network: bool) -> None: + self.manager = manager + self.allow_network = allow_network + + async def get_session(self) -> Optional[object]: + if not self.allow_network: + return None + return await self.manager.get_session() + + async def get_json(self, url: str) -> Optional[Any]: + if not self.allow_network: + raise RuntimeError("Network access disabled for plugins") + return await self.manager.get_json(url) diff --git a/core/loader.py b/core/loader.py new file mode 100644 index 0000000..baff208 --- /dev/null +++ b/core/loader.py @@ -0,0 +1,476 @@ +from __future__ import annotations + +import asyncio +import importlib +import inspect +import shutil +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from typing import Dict, List, Optional, Type + +from core.logger import get_logger +from core.audit import log as audit_log +from core.events import EventHandler +from core.versioning import is_compatible +from core.module import Module +from core.plugin import Plugin, PluginContext + + +logger = get_logger("core.loader") + + +class ModuleLoader: + def __init__(self, app: "OverUB", modules_path: Path) -> None: + self.app = app + self.modules_path = modules_path + self._loaded: Dict[str, Module] = {} + + async def load(self, module_path: str) -> Optional[Module]: + if module_path in self._loaded: + return self._loaded[module_path] + module = importlib.import_module(module_path) + module_class = self._find_module_class(module) + if module_class is None: + logger.warning("No Module class found in %s", module_path) + return None + await self._load_dependencies(module_class) + instance = module_class(self.app) + await instance.on_load() + self._loaded[module_path] = instance + audit_log("module_load", module_path) + return instance + + async def unload(self, module_path: str) -> None: + instance = self._loaded.pop(module_path, None) + if instance: + await instance.on_unload() + audit_log("module_unload", module_path) + + async def reload(self, module_path: str) -> Optional[Module]: + await self.unload(module_path) + if module_path in sys.modules: + importlib.reload(sys.modules[module_path]) + return await self.load(module_path) + + def list(self) -> List[str]: + return sorted(self._loaded.keys()) + + def list_installed(self) -> List[str]: + if not self.modules_path.exists(): + return [] + return sorted( + [ + item.name + for item in self.modules_path.iterdir() + if item.is_dir() and (item / "__init__.py").exists() + ] + ) + + def _find_module_class(self, module: ModuleType) -> Optional[Type[Module]]: + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Module) and obj is not Module: + return obj + return None + + async def _run(self, cmd: List[str]) -> str: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._sync_run, cmd) + + def _sync_run(self, cmd: List[str]) -> str: + logger.debug("Running command: %s", " ".join(cmd)) + result = subprocess.run(cmd, cwd=self.modules_path, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Command failed") + return result.stdout.strip() + + async def _load_dependencies(self, module_class: Type[Module]) -> None: + for dep in getattr(module_class, "dependencies", []) or []: + if dep.startswith("lib:"): + requirement = dep.split(":", 1)[1] + await self._run([sys.executable, "-m", "pip", "install", requirement]) + continue + if dep not in self._loaded: + await self.load(dep) + for dep in getattr(module_class, "optional_dependencies", []) or []: + if dep.startswith("lib:"): + requirement = dep.split(":", 1)[1] + try: + await self._run([sys.executable, "-m", "pip", "install", requirement]) + except Exception: + logger.debug("Optional dependency failed: %s", requirement) + continue + try: + if dep not in self._loaded: + await self.load(dep) + except Exception: + logger.debug("Optional module dependency failed: %s", dep) + + +class PluginManager: + def __init__(self, app: "OverUB", plugin_path: Path) -> None: + self.app = app + self.plugin_path = plugin_path + self._loaded: Dict[str, Plugin] = {} + self._handlers: Dict[str, List[tuple[str, EventHandler]]] = {} + self._error_counts: Dict[str, int] = {} + + def list_installed(self) -> List[str]: + if not self.plugin_path.exists(): + return [] + return sorted([item.name for item in self.plugin_path.iterdir() if item.is_dir()]) + + async def load(self, name: str) -> Optional[Plugin]: + if name in self._loaded: + return self._loaded[name] + sys.path.insert(0, str(self.plugin_path)) + try: + module = importlib.import_module(name) + plugin_class = self._find_plugin_class(module) + if plugin_class is None: + logger.warning("No Plugin class found in %s", name) + return None + await self._load_dependencies(plugin_class) + if self._has_conflicts(plugin_class): + logger.warning("Plugin %s conflicts with loaded plugins", name) + return None + if not await self._check_core_compat(plugin_class): + logger.warning("Plugin %s is not compatible with core", name) + return None + plugin_name = plugin_class.name or name + if not getattr(plugin_class, "name", ""): + plugin_class.name = plugin_name + if getattr(plugin_class, "config_schema", None): + self.app.config.register_plugin_schema(plugin_name, plugin_class.config_schema) + errors = self.app.config.validate_plugin_config(plugin_name) + if errors: + logger.warning("Plugin %s config issues: %s", plugin_name, ", ".join(errors)) + plugin = plugin_class(PluginContext(self.app, plugin_name)) + await plugin.on_load() + self._loaded[name] = plugin + await self._enable_if_configured(plugin) + audit_log("plugin_load", name) + return plugin + except Exception: + logger.exception("Failed to load plugin %s", name) + return None + + async def unload(self, name: str) -> None: + plugin = self._loaded.pop(name, None) + if plugin: + await self.disable(name) + await plugin.on_unload() + audit_log("plugin_unload", name) + + async def enable(self, name: str) -> None: + plugin = self._loaded.get(name) + if plugin: + self._register_hooks(plugin) + await plugin.on_enable() + audit_log("plugin_enable", name) + + async def disable(self, name: str) -> None: + plugin = self._loaded.get(name) + if plugin: + self._unregister_hooks(name) + await plugin.on_disable() + audit_log("plugin_disable", name) + + async def reload(self, name: str) -> Optional[Plugin]: + await self.unload(name) + if name in sys.modules: + importlib.reload(sys.modules[name]) + return await self.load(name) + + def list(self) -> List[str]: + return sorted(self._loaded.keys()) + + async def install(self, repo: str) -> str: + repo_url, ref = self._parse_repo(repo) + allowed = self.app.config.get().get("updates", {}).get("allowed_sources", []) + if allowed and not any(repo_url.startswith(src) for src in allowed): + raise RuntimeError("Source not allowed") + self.plugin_path.mkdir(parents=True, exist_ok=True) + dest_name = repo_url.rstrip("/").split("/")[-1] + dest_path = self.plugin_path / dest_name + if dest_path.exists(): + raise RuntimeError(f"Plugin {dest_name} already exists") + await self._run(["git", "clone", repo_url, str(dest_path)]) + if ref: + await self._run(["git", "checkout", ref], cwd=dest_path) + await self._verify_repo(dest_path) + self._ensure_init(dest_path) + await self._install_requirements(dest_path) + audit_log("plugin_install", dest_name) + return dest_name + + async def uninstall(self, name: str) -> None: + plugin_path = self.plugin_path / name + if plugin_path.exists(): + shutil.rmtree(plugin_path) + audit_log("plugin_uninstall", name) + + async def update(self, name: str) -> str: + plugin_path = self.plugin_path / name + if not plugin_path.exists(): + raise RuntimeError(f"Plugin {name} not found") + output = await self._run(["git", "pull"], cwd=plugin_path) + await self._verify_repo(plugin_path) + await self._install_requirements(plugin_path) + audit_log("plugin_update", name) + return output + + async def rollback(self, name: str, ref: str = "HEAD~1") -> str: + plugin_path = self.plugin_path / name + if not plugin_path.exists(): + raise RuntimeError(f"Plugin {name} not found") + output = await self._run(["git", "reset", "--hard", ref], cwd=plugin_path) + audit_log("plugin_rollback", name) + return output + + async def fetch(self, name: str) -> str: + plugin_path = self.plugin_path / name + if not plugin_path.exists(): + raise RuntimeError(f"Plugin {name} not found") + return await self._run(["git", "fetch"], cwd=plugin_path) + + async def remote(self, name: str) -> str: + plugin_path = self.plugin_path / name + if not plugin_path.exists(): + raise RuntimeError(f"Plugin {name} not found") + return await self._run(["git", "remote", "get-url", "origin"], cwd=plugin_path) + + async def info(self, name: str) -> Dict[str, str]: + plugin = self._loaded.get(name) + if plugin: + return { + "name": plugin.name, + "version": plugin.version, + "author": plugin.author, + "description": plugin.description, + "category": plugin.category, + } + info = {"name": name, "status": "not_loaded"} + try: + remote = await self.remote(name) + except Exception: + return info + owner_repo = self._parse_owner_repo(remote) + if owner_repo and self.app.gitea.api_url: + owner, repo = owner_repo + try: + data = self.app.gitea.repo_info(owner, repo) + except Exception: + return info + info.update( + { + "full_name": data.get("full_name", ""), + "stars": str(data.get("stars_count", "")), + "downloads": str(data.get("downloads", "")), + } + ) + return info + + async def search(self, query: str) -> str: + api_url = self.app.config.get().get("updates", {}).get("gitea", {}).get("api_url") + if api_url: + try: + results = self.app.gitea.search_repos(query) + except Exception as exc: + return f"Gitea API error: {exc}" + lines = [] + for item in results: + name = item.get("full_name", "") + if not name: + continue + stars = item.get("stars_count", 0) + lines.append(f"{name} ⭐{stars}") + return "\n".join(lines) + return await self._run(["tea", "repos", "search", query]) + + def _find_plugin_class(self, module: ModuleType) -> Optional[Type[Plugin]]: + for _, obj in inspect.getmembers(module, inspect.isclass): + if issubclass(obj, Plugin) and obj is not Plugin: + return obj + return None + + async def _enable_if_configured(self, plugin: Plugin) -> None: + config = self.app.config.get_plugin_config(plugin.name) + if config.get("enabled", True): + await self.enable(plugin.name) + + def _register_hooks(self, plugin: Plugin) -> None: + if plugin.name in self._handlers: + return + mapping = { + "on_startup": "on_startup", + "on_shutdown": "on_shutdown", + "on_ready": "on_ready", + "on_reconnect": "on_reconnect", + "on_disconnect": "on_disconnect", + "on_message": "on_message_new", + "on_message_new": "on_message_new", + "on_edit": "on_message_edit", + "on_message_edit": "on_message_edit", + "on_delete": "on_message_delete", + "on_message_delete": "on_message_delete", + "on_command": "on_command", + "on_message_read": "on_message_read", + "on_message_sent": "on_message_sent", + "on_inline_query": "on_inline_query", + "on_callback_query": "on_callback_query", + "on_chat_action": "on_chat_action", + "on_chat_update": "on_chat_update", + "on_typing": "on_typing", + "on_recording": "on_recording", + "on_user_update": "on_user_update", + "on_contact_update": "on_contact_update", + "on_status_update": "on_status_update", + } + handlers: List[tuple[str, EventHandler]] = [] + for method_name, event_name in mapping.items(): + handler = self._resolve_handler(plugin, method_name) + if handler: + wrapped = self._wrap_handler(plugin, handler) + self.app.events.on(event_name, wrapped) + handlers.append((event_name, wrapped)) + self._handlers[plugin.name] = handlers + + def _unregister_hooks(self, name: str) -> None: + handlers = self._handlers.pop(name, []) + for event_name, handler in handlers: + self.app.events.off(event_name, handler) + + def _resolve_handler(self, plugin: Plugin, method_name: str) -> Optional[EventHandler]: + base_method = getattr(Plugin, method_name, None) + handler = getattr(plugin, method_name, None) + if handler is None: + return None + if base_method is not None and getattr(handler, "__func__", None) == base_method: + return None + return handler + + def _wrap_handler(self, plugin: Plugin, handler: EventHandler) -> EventHandler: + async def wrapped(event): + cfg = self.app.config.get_plugin_config(plugin.name) + timeout = cfg.get("timeout") + try: + if timeout: + await asyncio.wait_for(handler(event), timeout=timeout) + else: + await handler(event) + except asyncio.TimeoutError: + logger.warning("Plugin %s timed out", plugin.name) + except Exception: + logger.exception("Plugin %s handler failed", plugin.name) + self._error_counts[plugin.name] = self._error_counts.get(plugin.name, 0) + 1 + limit = int(self.app.config.get().get("security", {}).get("plugin_error_limit", 3)) + if self._error_counts[plugin.name] >= limit: + logger.error("Disabling plugin %s after %s errors", plugin.name, limit) + await self.disable(plugin.name) + max_mem = cfg.get("max_memory_mb") or self.app.config.get().get("performance", {}).get("max_memory") + if max_mem: + try: + from core.monitor import get_system_stats + + stats = get_system_stats() + if stats.memory_mb and stats.memory_mb > float(max_mem): + logger.warning("Memory limit exceeded, disabling plugin %s", plugin.name) + await self.disable(plugin.name) + except Exception: + logger.debug("Memory check skipped") + max_cpu = cfg.get("max_cpu_percent") or self.app.config.get().get("performance", {}).get("max_cpu") + if max_cpu: + try: + from core.monitor import get_system_stats + + stats = get_system_stats() + if stats.cpu_percent and stats.cpu_percent > float(max_cpu): + logger.warning("CPU limit exceeded, disabling plugin %s", plugin.name) + await self.disable(plugin.name) + except Exception: + logger.debug("CPU check skipped") + return wrapped + + async def _load_dependencies(self, plugin_class: Type[Plugin]) -> None: + for dep in getattr(plugin_class, "dependencies", []) or []: + if dep.startswith("lib:"): + requirement = dep.split(":", 1)[1] + await self._run([sys.executable, "-m", "pip", "install", requirement]) + continue + if dep not in self._loaded: + await self.load(dep) + + def _has_conflicts(self, plugin_class: Type[Plugin]) -> bool: + conflicts = set(getattr(plugin_class, "conflicts", []) or []) + return any(conflict in self._loaded for conflict in conflicts) + + async def _check_core_compat(self, plugin_class: Type[Plugin]) -> bool: + min_version = getattr(plugin_class, "min_core_version", "") + max_version = getattr(plugin_class, "max_core_version", "") + if not min_version and not max_version: + return True + info = await self.app.updater.get_version_info() + return is_compatible(info.core, min_version, max_version) + + def _parse_repo(self, repo: str) -> tuple[str, Optional[str]]: + if "@" in repo: + repo, ref = repo.split("@", 1) + else: + ref = None + if "://" in repo or repo.startswith("git@"): + return repo, ref + base = self.app.config.get().get("updates", {}).get("git", {}).get("remote", "") + if base.startswith("http"): + root = "/".join(base.split("/")[:3]) + return f"{root}/{repo}", ref + raise RuntimeError("Repo URL must be a full URL or configure updates.git.remote") + + def _parse_owner_repo(self, url: str) -> Optional[tuple[str, str]]: + if url.startswith("http"): + parts = url.rstrip(".git").split("/") + if len(parts) >= 2: + return parts[-2], parts[-1] + if url.startswith("git@") and ":" in url: + path = url.split(":", 1)[1].rstrip(".git") + parts = path.split("/") + if len(parts) >= 2: + return parts[-2], parts[-1] + return None + + def _ensure_init(self, path: Path) -> None: + init_path = path / "__init__.py" + if init_path.exists(): + return + plugin_file = path / "plugin.py" + if plugin_file.exists(): + init_path.write_text("from .plugin import *\n", encoding="utf-8") + + async def _install_requirements(self, path: Path) -> None: + requirements = path / "requirements.txt" + if requirements.exists(): + await self._run([sys.executable, "-m", "pip", "install", "-r", str(requirements)]) + + async def _verify_repo(self, path: Path) -> None: + security = self.app.config.get().get("security", {}) + if not security.get("verify_plugin_commits", False): + return + commit = (await self._run(["git", "rev-parse", "HEAD"], cwd=path)).strip() + await self._run(["git", "verify-commit", commit], cwd=path) + allowed = security.get("allowed_signers", []) + if allowed: + signer = (await self._run(["git", "log", "--format=%GF", "-n", "1", commit], cwd=path)).strip() + if signer not in allowed: + raise RuntimeError("Plugin signer not allowed") + + async def _run(self, cmd: List[str], cwd: Optional[Path] = None) -> str: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._sync_run, cmd, cwd) + + def _sync_run(self, cmd: List[str], cwd: Optional[Path]) -> str: + logger.debug("Running command: %s", " ".join(cmd)) + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Command failed") + return result.stdout.strip() diff --git a/core/logger.py b/core/logger.py new file mode 100644 index 0000000..bca3b39 --- /dev/null +++ b/core/logger.py @@ -0,0 +1,106 @@ +import json +import logging +import logging.handlers +from pathlib import Path +from typing import Optional + + +DEFAULT_LOG_DIR = Path("data") / "logs" +MODULE_LOGS_ENABLED = False +LOG_JSON = False + + +class JSONFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "time": self.formatTime(record, "%Y-%m-%d %H:%M:%S"), + "level": record.levelname, + "name": record.name, + "message": record.getMessage(), + } + if record.exc_info: + payload["exc_info"] = self.formatException(record.exc_info) + return json.dumps(payload, ensure_ascii=True) + + +def setup_logging( + level: str = "INFO", + log_dir: Optional[Path] = None, + json_logs: bool = False, + module_logs: bool = False, + remote_url: str = "", +) -> None: + log_dir = log_dir or DEFAULT_LOG_DIR + log_dir.mkdir(parents=True, exist_ok=True) + global MODULE_LOGS_ENABLED, LOG_JSON + MODULE_LOGS_ENABLED = module_logs + LOG_JSON = json_logs + + logger = logging.getLogger() + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + + formatter: logging.Formatter + if json_logs: + formatter = JSONFormatter() + else: + formatter = logging.Formatter( + fmt="%(asctime)s %(levelname)s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + file_handler = logging.handlers.RotatingFileHandler( + log_dir / "overub.log", + maxBytes=2_000_000, + backupCount=5, + encoding="utf-8", + ) + file_handler.setFormatter(formatter) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + + if not logger.handlers: + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + if remote_url: + try: + from urllib.parse import urlparse + + parsed = urlparse(remote_url) + if parsed.scheme in {"http", "https"} and parsed.netloc: + http_handler = logging.handlers.HTTPHandler( + host=parsed.netloc, + url=parsed.path or "/", + method="POST", + ) + http_handler.setFormatter(formatter) + logger.addHandler(http_handler) + except Exception: + logger.debug("Remote logging not configured") + + +def get_logger(name: str) -> logging.Logger: + logger = logging.getLogger(name) + if MODULE_LOGS_ENABLED: + log_dir = DEFAULT_LOG_DIR / "modules" + log_dir.mkdir(parents=True, exist_ok=True) + handler_name = f"module_file_{name}" + if not any(getattr(h, "name", "") == handler_name for h in logger.handlers): + handler = logging.handlers.RotatingFileHandler( + log_dir / f"{name.replace('.', '_')}.log", + maxBytes=1_000_000, + backupCount=3, + encoding="utf-8", + ) + handler.name = handler_name + if LOG_JSON: + handler.setFormatter(JSONFormatter()) + else: + handler.setFormatter( + logging.Formatter( + fmt="%(asctime)s %(levelname)s %(name)s %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + logger.addHandler(handler) + return logger diff --git a/core/migrations.py b/core/migrations.py new file mode 100644 index 0000000..04e19a1 --- /dev/null +++ b/core/migrations.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import importlib.util +from pathlib import Path +from datetime import datetime +from typing import List, Optional + +from core.logger import get_logger + + +logger = get_logger("core.migrations") + + +class MigrationManager: + def __init__(self, root: Path) -> None: + self.root = root + self.migrations_path = root / "migrations" + self.migrations_path.mkdir(parents=True, exist_ok=True) + + def list_migrations(self) -> List[str]: + return sorted([item.name for item in self.migrations_path.glob("*.py")]) + + async def apply(self, app: "OverUB" = None) -> None: + if app is None: + return + await self._ensure_table(app) + applied = await self._applied(app) + for name in self.list_migrations(): + if name in applied: + continue + module = self._load_module(name) + if hasattr(module, "upgrade"): + result = module.upgrade(app) + if hasattr(result, "__await__"): + await result + await self._mark(applied, app, name) + logger.info("Migrations applied") + + async def rollback(self, app: "OverUB", name: Optional[str] = None, steps: int = 1) -> List[str]: + await self._ensure_table(app) + applied = await self._applied_with_time(app) + if name: + targets = [item for item in applied if item["name"] == name] + else: + targets = applied[:steps] + rolled = [] + for item in targets: + module = self._load_module(item["name"]) + if hasattr(module, "downgrade"): + result = module.downgrade(app) + if hasattr(result, "__await__"): + await result + await self._unmark(app, item["name"]) + rolled.append(item["name"]) + return rolled + + def validate(self) -> List[str]: + errors = [] + for name in self.list_migrations(): + module = self._load_module(name) + if not hasattr(module, "upgrade"): + errors.append(f"{name}: missing upgrade()") + return errors + + async def _ensure_table(self, app: "OverUB") -> None: + await app.database.execute( + "CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT)" + ) + columns = [] + if app.database.db_type == "sqlite": + rows = await app.database.fetchall("PRAGMA table_info(schema_migrations)") + columns = [row["name"] for row in rows] + elif app.database.db_type == "postgres": + rows = await app.database.fetchall( + "SELECT column_name AS name FROM information_schema.columns WHERE table_name='schema_migrations'" + ) + columns = [row["name"] for row in rows] + if columns and "applied_at" not in columns: + await app.database.execute("ALTER TABLE schema_migrations ADD COLUMN applied_at TEXT") + + async def _applied(self, app: "OverUB") -> List[str]: + rows = await app.database.fetchall("SELECT name FROM schema_migrations") + return [row["name"] for row in rows] + + async def _applied_with_time(self, app: "OverUB") -> List[dict]: + rows = await app.database.fetchall( + "SELECT name, applied_at FROM schema_migrations ORDER BY applied_at DESC" + ) + return rows + + async def _mark(self, applied: List[str], app: "OverUB", name: str) -> None: + if name in applied: + return + applied_at = datetime.utcnow().isoformat() + await app.database.execute( + "INSERT OR REPLACE INTO schema_migrations (name, applied_at) VALUES (?, ?)", + (name, applied_at), + ) + + async def _unmark(self, app: "OverUB", name: str) -> None: + await app.database.execute("DELETE FROM schema_migrations WHERE name=?", (name,)) + + def _load_module(self, name: str): + path = self.migrations_path / name + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + assert spec and spec.loader + spec.loader.exec_module(module) + return module diff --git a/core/module.py b/core/module.py new file mode 100644 index 0000000..6868869 --- /dev/null +++ b/core/module.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any, Dict + +from core.logger import get_logger + + +class Module: + name: str = "" + version: str = "0.1.0" + description: str = "" + dependencies: list[str] = [] + optional_dependencies: list[str] = [] + + def __init__(self, app: "OverUB") -> None: + self.app = app + self.log = get_logger(f"module.{self.name or self.__class__.__name__}") + self.commands = app.command_builder.with_owner(self.name, owner_type="module") + + async def on_load(self) -> None: + return None + + async def on_unload(self) -> None: + return None + + def get_config(self) -> Dict[str, Any]: + return self.app.config.get_module_config(self.name) + + def register_command(self, *args, **kwargs) -> Any: + return self.commands.command(*args, **kwargs) diff --git a/core/module_updates.py b/core/module_updates.py new file mode 100644 index 0000000..4f868ec --- /dev/null +++ b/core/module_updates.py @@ -0,0 +1,38 @@ +import asyncio +import subprocess +from pathlib import Path +from typing import List + +from core.logger import get_logger + + +logger = get_logger("core.module_updates") + + +class ModuleUpdateManager: + def __init__(self, path: Path, remote: str = "origin", branch: str = "main") -> None: + self.path = path + self.remote = remote + self.branch = branch + + async def check_updates(self) -> List[str]: + await self._run(["git", "fetch", self.remote]) + log = await self._run(["git", "log", f"HEAD..{self.remote}/{self.branch}", "--oneline"]) + return [line for line in log.splitlines() if line.strip()] + + async def update_all(self) -> str: + return await self._run(["git", "pull", self.remote, self.branch]) + + async def rollback(self, ref: str = "HEAD~1") -> str: + return await self._run(["git", "reset", "--hard", ref]) + + async def _run(self, cmd: List[str]) -> str: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._sync_run, cmd) + + def _sync_run(self, cmd: List[str]) -> str: + logger.debug("Running command: %s", " ".join(cmd)) + result = subprocess.run(cmd, cwd=self.path, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Command failed") + return result.stdout.strip() diff --git a/core/monitor.py b/core/monitor.py new file mode 100644 index 0000000..b4e4c4f --- /dev/null +++ b/core/monitor.py @@ -0,0 +1,21 @@ +import os +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class SystemStats: + cpu_percent: Optional[float] + memory_mb: Optional[float] + + +def get_system_stats() -> SystemStats: + try: + import psutil + except ImportError: + return SystemStats(cpu_percent=None, memory_mb=None) + + process = psutil.Process(os.getpid()) + cpu = psutil.cpu_percent(interval=None) + mem = process.memory_info().rss / (1024 * 1024) + return SystemStats(cpu_percent=cpu, memory_mb=mem) diff --git a/core/notifications.py b/core/notifications.py new file mode 100644 index 0000000..f6232a2 --- /dev/null +++ b/core/notifications.py @@ -0,0 +1,46 @@ +from datetime import datetime +from typing import List + +from core.logger import get_logger + + +logger = get_logger("core.notifications") + + +class Notifier: + def __init__(self, app: "OverUB") -> None: + self.app = app + cfg = app.config.get().get("notifications", {}) + self.channels: List[str] = cfg.get("channels", ["telegram"]) + self.enabled = bool(cfg.get("enabled", False)) + self.quiet_hours = cfg.get("quiet_hours", {}) + self.events = cfg.get("events", []) + + async def notify(self, text: str, event: str = "") -> None: + if not self.enabled: + return + if event and self.events and event not in self.events: + return + if self._in_quiet_hours(): + return + if "telegram" in self.channels and self.app.client.client: + try: + await self.app.client.client.send_message("me", text) + except Exception: + logger.exception("Telegram notification failed") + if "desktop" in self.channels: + logger.info("Desktop notification: %s", text) + if "email" in self.channels: + logger.info("Email notification: %s", text) + if "webhook" in self.channels: + logger.info("Webhook notification: %s", text) + + def _in_quiet_hours(self) -> bool: + start = self.quiet_hours.get("start") + end = self.quiet_hours.get("end") + if not start or not end: + return False + now = datetime.now().strftime("%H:%M") + if start <= end: + return start <= now <= end + return now >= start or now <= end diff --git a/core/permissions.py b/core/permissions.py new file mode 100644 index 0000000..35db483 --- /dev/null +++ b/core/permissions.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, field +from typing import Dict, List, Set + + +@dataclass +class PermissionProfile: + name: str + users: List[int] = field(default_factory=list) + chats: List[int] = field(default_factory=list) + + +class PermissionManager: + def __init__(self) -> None: + self._profiles: Dict[str, PermissionProfile] = {} + self._allowed: Set[int] = set() + self._blocked: Set[int] = set() + self._sudo: Set[int] = set() + + def add_profile(self, profile: PermissionProfile) -> None: + self._profiles[profile.name] = profile + + def load_from_config(self, config: Dict[str, List[int]]) -> None: + self._allowed = set(config.get("allowed_users", []) or []) + self._blocked = set(config.get("blocked_users", []) or []) + self._sudo = set(config.get("sudo_users", []) or []) + + def is_allowed(self, permission: str, user_id: int, chat_id: int) -> bool: + permission = permission.lower() + if user_id in self._blocked: + return False + if permission == "core": + return False + if permission == "admin": + return user_id in self._sudo + if permission == "trusted": + return user_id in self._sudo or user_id in self._allowed + if permission == "sandbox": + return False + profile = self._profiles.get(permission) + if profile is None: + if permission != "user": + return False + if self._allowed: + return user_id in self._allowed or user_id in self._sudo + return True + if user_id in profile.users: + return True + if chat_id in profile.chats: + return True + return False diff --git a/core/plugin.py b/core/plugin.py new file mode 100644 index 0000000..15c074d --- /dev/null +++ b/core/plugin.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from core.logger import get_logger + + +class Plugin: + name: str = "" + version: str = "0.1.0" + author: str = "" + description: str = "" + dependencies: List[str] = [] + category: str = "" + conflicts: List[str] = [] + config_schema: Dict[str, Any] = {} + config_version: str = "1.0.0" + min_core_version: str = "" + max_core_version: str = "" + + def __init__(self, context: "PluginContext") -> None: + self.context = context + self.log = get_logger(f"plugin.{self.name or self.__class__.__name__}") + self.db = context.database.plugin_db(self.name) + plugin_cfg = context.config.get_plugin_config(self.name) + self.commands = context.command_builder.with_owner( + self.name, + owner_type="plugin", + prefix=plugin_cfg.get("command_prefix"), + ) + self.sandbox = context.sandbox.for_plugin(self.name) + + async def on_load(self) -> None: + return None + + async def on_unload(self) -> None: + return None + + async def on_enable(self) -> None: + return None + + async def on_disable(self) -> None: + return None + + async def on_message(self, event: Any) -> None: + return None + + async def on_message_new(self, event: Any) -> None: + return None + + async def on_edit(self, event: Any) -> None: + return None + + async def on_message_edit(self, event: Any) -> None: + return None + + async def on_delete(self, event: Any) -> None: + return None + + async def on_message_delete(self, event: Any) -> None: + return None + + async def on_message_read(self, event: Any) -> None: + return None + + async def on_message_sent(self, event: Any) -> None: + return None + + async def on_command(self, event: Any) -> None: + return None + + async def on_inline_query(self, event: Any) -> None: + return None + + async def on_callback_query(self, event: Any) -> None: + return None + + async def on_chat_action(self, event: Any) -> None: + return None + + async def on_chat_update(self, event: Any) -> None: + return None + + async def on_typing(self, event: Any) -> None: + return None + + async def on_recording(self, event: Any) -> None: + return None + + async def on_user_update(self, event: Any) -> None: + return None + + async def on_contact_update(self, event: Any) -> None: + return None + + async def on_status_update(self, event: Any) -> None: + return None + + def get_config(self) -> Dict[str, Any]: + return self.context.config.get_plugin_config(self.name) + + def set_config(self, data: Dict[str, Any]) -> None: + self.context.config.set_plugin_config(self.name, data) + + def get_secret(self, key: str) -> Any: + cfg = self.get_config().get("secrets", {}) + value = cfg.get(key) + return self.context.config.decrypt_value(value) if value else None + + def set_secret(self, key: str, value: str) -> None: + cfg = self.get_config() + secrets = cfg.setdefault("secrets", {}) + secrets[key] = self.context.config.encrypt_value(value) + self.set_config(cfg) + + def get_service(self, name: str) -> Optional[Any]: + return self.context.bus.get_service(name) + + def register_service(self, name: str, service: Any) -> None: + self.context.bus.register_service(name, service) + + def register_command(self, *args, **kwargs) -> Any: + return self.commands.command(*args, **kwargs) + + +class PluginContext: + def __init__(self, app: "OverUB", plugin_name: str) -> None: + from core.config import PluginConfigProxy + + self.app = app + self.config = PluginConfigProxy(app.config, plugin_name) + self.bus = app.bus + self.database = app.database + self.cache = app.cache + self.rate_limiter = app.rate_limiter + self.commands = app.commands + self.command_builder = app.command_builder + self.sandbox = app.sandbox + from core.http import RestrictedSession + + self.http = RestrictedSession(app.http, app.sandbox.allow_network) + self.events = app.events + self.permissions = app.permissions diff --git a/core/profiler.py b/core/profiler.py new file mode 100644 index 0000000..cb43b01 --- /dev/null +++ b/core/profiler.py @@ -0,0 +1,14 @@ +import cProfile +import pstats +from pathlib import Path +from typing import Callable + + +def profile(func: Callable, output: Path) -> None: + profiler = cProfile.Profile() + profiler.enable() + func() + profiler.disable() + output.parent.mkdir(parents=True, exist_ok=True) + stats = pstats.Stats(profiler) + stats.dump_stats(str(output)) diff --git a/core/rate_limiter.py b/core/rate_limiter.py new file mode 100644 index 0000000..5f09bad --- /dev/null +++ b/core/rate_limiter.py @@ -0,0 +1,23 @@ +import time +from dataclasses import dataclass +from typing import Dict, Tuple + + +@dataclass +class RateLimit: + limit: int + window: int + + +class RateLimiter: + def __init__(self) -> None: + self._hits: Dict[Tuple[str, int], list[float]] = {} + + def check(self, key: str, user_id: int, limit: RateLimit) -> bool: + now = time.time() + bucket = self._hits.setdefault((key, user_id), []) + bucket[:] = [stamp for stamp in bucket if now - stamp < limit.window] + if len(bucket) >= limit.limit: + return False + bucket.append(now) + return True diff --git a/core/sandbox.py b/core/sandbox.py new file mode 100644 index 0000000..89cb461 --- /dev/null +++ b/core/sandbox.py @@ -0,0 +1,29 @@ +from pathlib import Path +from typing import Optional + + +class Sandbox: + def __init__(self, root: Path, allow_network: bool = False) -> None: + self.root = root + self.allow_network = allow_network + + def _resolve(self, path: str) -> Path: + target = (self.root / path).resolve() + if not str(target).startswith(str(self.root.resolve())): + raise PermissionError("Path outside sandbox") + return target + + def read_text(self, path: str, encoding: str = "utf-8") -> str: + target = self._resolve(path) + return target.read_text(encoding=encoding) + + def write_text(self, path: str, data: str, encoding: str = "utf-8") -> None: + target = self._resolve(path) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(data, encoding=encoding) + + def can_network(self) -> bool: + return self.allow_network + + def for_plugin(self, name: str) -> "Sandbox": + return Sandbox(self.root / "external" / name, allow_network=self.allow_network) diff --git a/core/scheduler.py b/core/scheduler.py new file mode 100644 index 0000000..deb1365 --- /dev/null +++ b/core/scheduler.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Optional + + +@dataclass +class ScheduleConfig: + time: str + postpone_on_activity: bool = True + max_downtime: int = 120 + retry_failed: bool = True + retry_interval: int = 3600 + + +class Scheduler: + def __init__(self, config: ScheduleConfig) -> None: + self.config = config + self._last_run: Optional[datetime] = None + self._last_failed: Optional[datetime] = None + + def should_run(self, last_activity: Optional[datetime] = None) -> bool: + now = datetime.now() + if self._last_run and self._last_run.date() == now.date(): + return False + if self._last_failed and self.config.retry_failed: + if now - self._last_failed >= timedelta(seconds=self.config.retry_interval): + return True + target = datetime.strptime(self.config.time, "%H:%M").time() + target_dt = datetime.combine(now.date(), target) + if now < target_dt: + return False + if self.config.postpone_on_activity and last_activity: + if (now - last_activity).total_seconds() < self.config.max_downtime: + return False + return True + + def mark_run(self) -> None: + self._last_run = datetime.now() + self._last_failed = None + + def mark_failed(self) -> None: + self._last_failed = datetime.now() diff --git a/core/testing.py b/core/testing.py new file mode 100644 index 0000000..674b194 --- /dev/null +++ b/core/testing.py @@ -0,0 +1,80 @@ +from dataclasses import dataclass, field +from typing import Any, Callable, Optional + + +@dataclass +class MockUser: + id: int + + +@dataclass +class MockMessage: + raw_text: str = "" + replies: list[str] = field(default_factory=list) + reply_text: Optional[str] = None + + async def reply(self, text: str) -> None: + self.replies.append(text) + self.reply_text = text + + +class MockEvent: + def __init__(self, text: str = "", sender_id: int = 0, chat_id: int = 0) -> None: + self.message = MockMessage(raw_text=text) + self.sender_id = sender_id + self.chat_id = chat_id + self.out = False + + async def reply(self, text: str) -> None: + await self.message.reply(text) + self.reply_text = text + + +class PluginTest: + def __init__(self, app: Any = None, plugin_name: str = "") -> None: + self.app = app + self.plugin_name = plugin_name + + async def test_command( + self, + command: str | Callable[..., Any], + args: Optional[list[str]] = None, + event: Optional[Any] = None, + ) -> Any: + args = args or [] + if callable(command): + handler = command + else: + if not self.app: + raise RuntimeError("App required for command lookup") + cmd = self.app.commands.get(command) + if not cmd: + raise RuntimeError("Command not found") + handler = cmd.handler + event = event or MockEvent() + await handler(event, args) + return event + + async def test_events(self, event_name: str, payload: Optional[dict] = None) -> Any: + if not self.app: + raise RuntimeError("App required for event dispatch") + payload = payload or {} + return await self.app.events.emit(event_name, **payload) + + async def test_database(self, key: str = "test", value: Any = None) -> Any: + if not self.app or not self.plugin_name: + raise RuntimeError("App and plugin_name required") + db = self.app.database.plugin_db(self.plugin_name) + await db.set(key, value) + return await db.get(key) + + async def mock_message(self, text: str = "") -> Any: + return MockMessage(raw_text=text) + + async def mock_user(self, user_id: int) -> Any: + return MockUser(id=user_id) + + async def assert_reply(self, event: Any, expected: str) -> None: + actual = getattr(event, "reply_text", None) + if actual != expected: + raise AssertionError(f"Expected '{expected}', got '{actual}'") diff --git a/core/update_service.py b/core/update_service.py new file mode 100644 index 0000000..fb51ddf --- /dev/null +++ b/core/update_service.py @@ -0,0 +1,238 @@ +from __future__ import annotations + +import asyncio +import contextlib +import json +import time +from datetime import datetime +from typing import List + +from core.changelog import format_changelog, parse_conventional +from core.logger import get_logger +from core.notifications import Notifier +from core.scheduler import ScheduleConfig, Scheduler +from core.webhook import parse_webhook, verify_signature + + +logger = get_logger("core.update_service") + + +class UpdateService: + def __init__(self, app: "OverUB") -> None: + self.app = app + self.config = app.config.get().get("updates", {}) + self.notifier = Notifier(app) + self._scheduler = self._build_scheduler() + self._history_path = app.root / "data" / "update_history.jsonl" + self._task: asyncio.Task | None = None + + def _build_scheduler(self) -> Scheduler: + scheduler_cfg = self.app.config.get().get("scheduler", {}) + update_time = self.config.get("update_time", "03:00") + time_value = scheduler_cfg.get("auto_update_time", update_time) + return Scheduler( + ScheduleConfig( + time=time_value, + postpone_on_activity=bool(scheduler_cfg.get("postpone_on_activity", True)), + max_downtime=int(scheduler_cfg.get("max_downtime", 120)), + retry_failed=bool(scheduler_cfg.get("retry_failed", True)), + retry_interval=int(scheduler_cfg.get("retry_interval", 3600)), + ) + ) + + def _refresh_scheduler(self) -> None: + scheduler_cfg = self.app.config.get().get("scheduler", {}) + update_time = self.config.get("update_time", "03:00") + self._scheduler.config.time = scheduler_cfg.get("auto_update_time", update_time) + self._scheduler.config.postpone_on_activity = bool( + scheduler_cfg.get("postpone_on_activity", True) + ) + self._scheduler.config.max_downtime = int(scheduler_cfg.get("max_downtime", 120)) + self._scheduler.config.retry_failed = bool(scheduler_cfg.get("retry_failed", True)) + self._scheduler.config.retry_interval = int(scheduler_cfg.get("retry_interval", 3600)) + + async def check_updates(self) -> List[str]: + return await self.app.updater.check_updates() + + async def apply_updates(self) -> str: + start = time.monotonic() + commits = await self.app.updater.get_commits_ahead() + diff_stats = await self.app.updater.get_diff_stats() + try: + allowed = self.config.get("allowed_sources", []) + if allowed: + remote = await self.app.updater.get_remote_url() + if not any(remote.startswith(src) for src in allowed): + raise RuntimeError("Remote not allowed") + if self.config.get("backup_before_update", True): + self.app.backups.create("core") + if self.config.get("verify_commits", False): + commits = await self.app.updater.get_commits_ahead() + allowed = self.app.config.get().get("security", {}).get("allowed_signers", []) + for line in commits: + commit = line.split(" ", 1)[0] + if not await self.app.updater.verify_commit(commit, allowed_signers=allowed): + raise RuntimeError("Commit verification failed") + output = await self.app.updater.pull_updates() + except Exception as exc: + self.record_event( + action="core_update", + status="failed", + meta={"error": str(exc)}, + ) + raise + duration = time.monotonic() - start + self.record_event( + action="core_update", + status="success", + meta={ + "commits": len(commits), + "duration": duration, + "lines_added": diff_stats.get("added", 0), + "lines_deleted": diff_stats.get("deleted", 0), + }, + ) + self._log_update(output) + return output + + async def rollback(self, ref: str = "HEAD~1") -> str: + output = await self.app.updater.rollback(ref) + self.record_event(action="core_rollback", status="success", meta={"ref": ref}) + self._log_update(f"rollback {ref}") + return output + + async def notify_updates(self, commits: List[str]) -> None: + if not commits: + return + buckets = parse_conventional(commits) + summary = ["OverUB Update Available", format_changelog(buckets, inline=True)] + await self.notifier.notify("\n".join(summary), event="update_available") + + async def _run_loop(self) -> None: + while True: + self.config = self.app.config.get().get("updates", {}) + self._refresh_scheduler() + interval = int(self.config.get("check_interval", 3600)) + auto_update = bool(self.config.get("auto_update", False)) + try: + commits = await self.check_updates() + if commits: + if self.config.get("notify", True): + await self.notify_updates(commits) + if auto_update and self._scheduler.should_run(self.app.last_activity): + output = await self.apply_updates() + self._scheduler.mark_run() + await self.notifier.notify(output or "Updated", event="update_completed") + await self._auto_update_plugins() + except Exception: + logger.exception("Update check failed") + self._scheduler.mark_failed() + self.record_event(action="core_update", status="failed", meta={}) + await self.notifier.notify("Update failed", event="update_failed") + await asyncio.sleep(interval) + + def start(self) -> None: + if self._task and not self._task.done(): + return + self._task = asyncio.create_task(self._run_loop()) + + def _log_update(self, output: str) -> None: + path = self.app.root / "data" / "update_history.log" + path.parent.mkdir(parents=True, exist_ok=True) + stamp = datetime.utcnow().isoformat() + with path.open("a", encoding="utf-8") as handle: + handle.write(f"{stamp} {output}\n") + + def stats(self) -> dict[str, object]: + history = self._read_history() + core = [item for item in history if item.get("action") == "core_update"] + core_success = [item for item in core if item.get("status") == "success"] + modules = [item for item in history if item.get("action") == "module_update"] + plugins = [item for item in history if item.get("action") == "plugin_update"] + last = max((item.get("timestamp", "") for item in history), default="") + avg_duration = 0.0 + if core_success: + avg_duration = sum(item.get("duration", 0.0) for item in core_success) / len(core_success) + total_lines = sum(item.get("lines_added", 0) + item.get("lines_deleted", 0) for item in core) + return { + "core_updates": len(core), + "core_success": len(core_success), + "module_updates": len(modules), + "plugin_updates": len(plugins), + "last_update": last, + "avg_update_time": round(avg_duration, 2), + "total_lines_changed": total_lines, + } + + def dashboard(self) -> str: + stats = self.stats() + core_total = stats["core_updates"] + core_success = stats["core_success"] + success_rate = 0 + if core_total: + success_rate = int((core_success / core_total) * 100) + lines = [ + "Update Statistics", + f"Core Updates: {core_total} ({success_rate}% success)", + f"Module Updates: {stats['module_updates']}", + f"Plugin Updates: {stats['plugin_updates']}", + f"Last Update: {stats['last_update'] or 'never'}", + f"Average Update Time: {stats['avg_update_time']}s", + f"Total Downloaded: {stats['total_lines_changed']} lines", + ] + return "\n".join(lines) + + async def _auto_update_plugins(self) -> None: + plugin_cfg = self.app.config.get().get("plugins", {}) + if not plugin_cfg.get("auto_update", False): + return + for name in self.app.plugins.list_installed(): + cfg = self.app.config.get_plugin_config(name) + if cfg.get("auto_update", True) is False: + continue + try: + await self.app.plugins.update(name) + self.record_event(action="plugin_update", status="success", meta={"name": name}) + except Exception: + logger.exception("Plugin update failed: %s", name) + self.record_event(action="plugin_update", status="failed", meta={"name": name}) + + async def handle_webhook(self, payload: dict, signature: str = "") -> None: + secret = self.config.get("gitea", {}).get("webhook_secret", "") + if secret and signature: + if not verify_signature(secret, json.dumps(payload).encode("utf-8"), signature): + raise RuntimeError("Invalid webhook signature") + info = parse_webhook(payload) + if info.get("event") in {"push", "release", "tag"}: + await self.apply_updates() + + async def stop(self) -> None: + if self._task: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + + def record_event(self, action: str, status: str, meta: dict | None = None) -> None: + entry = { + "timestamp": datetime.utcnow().isoformat(), + "action": action, + "status": status, + } + if meta: + entry.update(meta) + self._history_path.parent.mkdir(parents=True, exist_ok=True) + with self._history_path.open("a", encoding="utf-8") as handle: + handle.write(json.dumps(entry) + "\n") + + def _read_history(self) -> list[dict]: + if not self._history_path.exists(): + return [] + entries = [] + for line in self._history_path.read_text(encoding="utf-8").splitlines(): + if not line.strip(): + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries diff --git a/core/updater.py b/core/updater.py new file mode 100644 index 0000000..5f837a5 --- /dev/null +++ b/core/updater.py @@ -0,0 +1,234 @@ +from __future__ import annotations + +import asyncio +import subprocess +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from core.changelog import format_changelog, parse_conventional +from core.logger import get_logger + + +logger = get_logger("core.updater") + + +@dataclass +class VersionInfo: + core: str + commit: str + short_commit: str + branch: str + remote: str + channel: str + dirty: bool + date: datetime + + +class UpdateManager: + def __init__(self, repo_path: Path, remote: str, branch: str) -> None: + self.repo_path = repo_path + self.remote = remote + self.branch = branch + self.gitea = None + + async def check_updates(self) -> List[str]: + await self.fetch_updates() + log = await self._run(["git", "log", f"HEAD..{self.remote}/{self.branch}", "--oneline"]) + return [line for line in log.splitlines() if line.strip()] + + async def fetch_updates(self) -> str: + return await self._run(["git", "fetch", self.remote]) + + async def pull_updates(self) -> str: + return await self._run(["git", "pull", self.remote, self.branch]) + + async def checkout_version(self, ref: str) -> str: + return await self._run(["git", "checkout", ref]) + + async def rollback(self, commit: str) -> str: + return await self._run(["git", "reset", "--hard", commit]) + + async def get_current_commit(self) -> str: + return (await self._run(["git", "rev-parse", "HEAD"])).strip() + + async def get_current_branch(self) -> str: + return (await self._run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).strip() + + async def get_remote_url(self) -> str: + return (await self._run(["git", "remote", "get-url", self.remote])).strip() + + async def list_tags(self) -> List[str]: + output = await self._run(["git", "tag"]) + return [tag for tag in output.splitlines() if tag.strip()] + + async def get_commits_ahead(self) -> List[str]: + log = await self._run(["git", "log", f"HEAD..{self.remote}/{self.branch}", "--oneline"]) + return [line for line in log.splitlines() if line.strip()] + + async def get_changelog(self, ref: str = "HEAD") -> str: + if self.gitea and ref != "HEAD": + owner_repo = await self._get_owner_repo() + if owner_repo: + owner, repo = owner_repo + release = await self.get_release(owner, repo, ref) + body = release.get("body") or release.get("note") + if body: + return body + output = await self._run(["git", "log", ref, "--oneline"]) + commits = [line for line in output.splitlines() if line.strip()] + buckets = parse_conventional(commits) + return format_changelog(buckets) + + async def get_changelog_since(self, ref: str) -> str: + output = await self._run(["git", "log", f"{ref}..HEAD", "--oneline"]) + commits = [line for line in output.splitlines() if line.strip()] + buckets = parse_conventional(commits) + return format_changelog(buckets) + + async def get_changelog_between(self, start: str, end: str) -> str: + output = await self._run(["git", "log", f"{start}..{end}", "--oneline"]) + commits = [line for line in output.splitlines() if line.strip()] + buckets = parse_conventional(commits) + return format_changelog(buckets) + + async def search_commits(self, keyword: str) -> str: + output = await self._run(["git", "log", "--oneline", "--grep", keyword]) + commits = [line for line in output.splitlines() if line.strip()] + if not commits: + return "No matching commits" + buckets = parse_conventional(commits) + return format_changelog(buckets) + + async def get_unreleased_changelog(self) -> str: + try: + last_tag = (await self._run(["git", "describe", "--tags", "--abbrev=0"])).strip() + except RuntimeError: + last_tag = "" + if not last_tag: + return await self.get_changelog("HEAD") + return await self.get_changelog_since(last_tag) + + async def get_diff_stats(self) -> dict: + output = await self._run(["git", "diff", "--numstat", f"HEAD..{self.remote}/{self.branch}"]) + added = 0 + deleted = 0 + for line in output.splitlines(): + parts = line.split("\t") + if len(parts) < 2: + continue + add, remove = parts[0], parts[1] + if add.isdigit(): + added += int(add) + if remove.isdigit(): + deleted += int(remove) + return {"added": added, "deleted": deleted} + + async def verify_commit(self, commit_hash: str, allowed_signers: Optional[list[str]] = None) -> bool: + try: + await self._run(["git", "verify-commit", commit_hash]) + except RuntimeError: + return False + if allowed_signers: + signer = await self._get_signer(commit_hash) + return signer in allowed_signers + return True + + async def get_releases(self, owner: str, repo: str) -> list[dict]: + if not self.gitea: + raise RuntimeError("Gitea client not configured") + return self.gitea.releases(owner, repo) + + async def get_latest_release(self, owner: str, repo: str) -> dict: + releases = await self.get_releases(owner, repo) + return releases[0] if releases else {} + + async def get_release(self, owner: str, repo: str, tag: str) -> dict: + releases = await self.get_releases(owner, repo) + for release in releases: + if release.get("tag_name") == tag: + return release + return {} + + async def download_release(self, owner: str, repo: str, tag: str) -> dict: + release = await self.get_release(owner, repo, tag) + if not release: + raise RuntimeError("Release not found") + assets = release.get("assets", []) + if not assets: + return release + asset = assets[0] + url = asset.get("browser_download_url") + if not url: + return release + return {"release": release, "asset_url": url} + + async def download_release_asset(self, url: str, dest: Path) -> Path: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._sync_download, url, dest) + + def _sync_download(self, url: str, dest: Path) -> Path: + from urllib import request + + dest.parent.mkdir(parents=True, exist_ok=True) + with request.urlopen(url) as response: + data = response.read() + dest.write_bytes(data) + return dest + + async def get_version_info(self) -> VersionInfo: + commit = await self.get_current_commit() + branch = await self.get_current_branch() + remote = await self.get_remote_url() + core_version = await self._get_core_version() + short_commit = commit[:7] + dirty = bool((await self._run(["git", "status", "--porcelain"])).strip()) + date = datetime.utcnow() + return VersionInfo( + core=core_version, + commit=commit, + short_commit=short_commit, + branch=branch, + remote=remote, + channel=branch, + dirty=dirty, + date=date, + ) + + async def _get_core_version(self) -> str: + try: + return (await self._run(["git", "describe", "--tags", "--abbrev=0"])).strip() + except RuntimeError: + return "unknown" + + async def _run(self, cmd: List[str]) -> str: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, self._sync_run, cmd) + + def _sync_run(self, cmd: List[str]) -> str: + logger.debug("Running command: %s", " ".join(cmd)) + result = subprocess.run(cmd, cwd=self.repo_path, capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "Command failed") + return result.stdout + + async def _get_signer(self, commit_hash: str) -> str: + output = await self._run(["git", "log", "--format=%GF", "-n", "1", commit_hash]) + return output.strip() + + async def _get_owner_repo(self) -> Optional[tuple[str, str]]: + try: + remote = await self.get_remote_url() + except Exception: + return None + if remote.startswith("http"): + parts = remote.rstrip(".git").split("/") + if len(parts) >= 2: + return parts[-2], parts[-1] + if remote.startswith("git@") and ":" in remote: + path = remote.split(":", 1)[1].rstrip(".git") + parts = path.split("/") + if len(parts) >= 2: + return parts[-2], parts[-1] + return None diff --git a/core/version.py b/core/version.py new file mode 100644 index 0000000..1c00fb9 --- /dev/null +++ b/core/version.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict + + +@dataclass +class VersionReport: + core: str + commit: str + short_commit: str + branch: str + remote: str + channel: str + build: int + date: datetime + dirty: bool + modules: Dict[str, str] = field(default_factory=dict) + plugins: Dict[str, str] = field(default_factory=dict) + + +class VersionManager: + def __init__(self, app: "OverUB") -> None: + self.app = app + + async def get_report(self) -> VersionReport: + info = await self.app.updater.get_version_info() + modules = {name: mod.version for name, mod in self.app.modules._loaded.items()} + plugins = {name: plugin.version for name, plugin in self.app.plugins._loaded.items()} + return VersionReport( + core=info.core, + commit=info.commit, + short_commit=info.short_commit, + branch=info.branch, + remote=info.remote, + channel=info.channel, + build=0, + date=info.date, + dirty=info.dirty, + modules=modules, + plugins=plugins, + ) diff --git a/core/versioning.py b/core/versioning.py new file mode 100644 index 0000000..fb2cc42 --- /dev/null +++ b/core/versioning.py @@ -0,0 +1,18 @@ +from typing import Tuple + + +def parse_version(value: str) -> Tuple[int, int, int]: + parts = value.strip().lstrip("v").split(".") + nums = [int(part) if part.isdigit() else 0 for part in parts[:3]] + while len(nums) < 3: + nums.append(0) + return nums[0], nums[1], nums[2] + + +def is_compatible(core: str, minimum: str, maximum: str = "") -> bool: + core_v = parse_version(core) + if minimum and core_v < parse_version(minimum): + return False + if maximum and core_v > parse_version(maximum): + return False + return True diff --git a/core/webhook.py b/core/webhook.py new file mode 100644 index 0000000..a66fec5 --- /dev/null +++ b/core/webhook.py @@ -0,0 +1,18 @@ +import hmac +import hashlib +from typing import Any, Dict + + +def verify_signature(secret: str, payload: bytes, signature: str) -> bool: + if not secret: + return False + computed = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + return hmac.compare_digest(computed, signature) + + +def parse_webhook(payload: Dict[str, Any]) -> Dict[str, Any]: + return { + "event": payload.get("action") or payload.get("event"), + "repository": payload.get("repository", {}).get("full_name"), + "ref": payload.get("ref"), + } diff --git a/core/webhook_server.py b/core/webhook_server.py new file mode 100644 index 0000000..47cd206 --- /dev/null +++ b/core/webhook_server.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Optional + +from core.logger import get_logger + + +logger = get_logger("core.webhook_server") + + +class WebhookServer: + def __init__(self, app: "OverUB", host: str, port: int) -> None: + self.app = app + self.host = host + self.port = port + self._runner = None + + async def start(self) -> None: + try: + from aiohttp import web + except ImportError: + logger.warning("aiohttp not installed, webhook server disabled") + return + + async def handler(request: web.Request) -> web.Response: + payload = await request.json() + signature = request.headers.get("X-Gitea-Signature", "") + try: + await self.app.update_service.handle_webhook(payload, signature) + except Exception as exc: + logger.exception("Webhook handling failed") + return web.Response(status=400, text=str(exc)) + return web.Response(text="ok") + + web_app = web.Application() + web_app.add_routes([web.post("/webhook/gitea", handler)]) + self._runner = web.AppRunner(web_app) + await self._runner.setup() + site = web.TCPSite(self._runner, self.host, self.port) + await site.start() + logger.info("Webhook server started on %s:%s", self.host, self.port) + + async def stop(self) -> None: + if self._runner: + await self._runner.cleanup() + self._runner = None diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md new file mode 100644 index 0000000..c6c8f95 --- /dev/null +++ b/docs/API_REFERENCE.md @@ -0,0 +1,49 @@ +# OverUB API Reference + +## Plugin Base Class +See `core/plugin.py` for the base class and lifecycle hooks. + +## Event System +See `core/events.py` for event dispatch, cancellation, and priorities. + +## Command System +See `core/commands.py` for command registration and parsing. + +## Message Bus +See `core/bus.py` for pub/sub and shared service registry. + +## Cache +See `core/cache.py` for in-memory LRU cache helpers. + +## Plugin Database +See `core/database.py` for `PluginDatabase` key-value helpers. + +## Backups +See `core/backup.py` for basic backup utilities. + +## Backup Scheduler +See `core/backup_service.py` for scheduled backup automation. + +## Scheduler +See `core/scheduler.py` for scheduling helpers. + +## Update Service +See `core/update_service.py` for update scheduling and notifications. + +## Gitea Client +See `core/gitea.py` for basic API integration. + +## Sandbox +See `core/sandbox.py` for plugin sandbox helpers. + +## HTTP Session +See `core/http.py` for shared HTTP session access. + +## Monitoring +See `core/monitor.py` for CPU/memory stats. + +## Rate Limiting +See `core/rate_limiter.py` for rate limiter helpers. + +## Migrations +See `core/migrations.py` for migration hooks. diff --git a/docs/BEST_PRACTICES.md b/docs/BEST_PRACTICES.md new file mode 100644 index 0000000..1d3dcfc --- /dev/null +++ b/docs/BEST_PRACTICES.md @@ -0,0 +1,8 @@ +# OverUB Plugin Best Practices + +- Keep plugins small and single-purpose. +- Use the message bus for cross-plugin coordination. +- Handle exceptions and avoid blocking operations. +- Respect plugin timeouts and rate limits. +- Store configuration via the plugin config helpers. +- Respect permission checks before acting. diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md new file mode 100644 index 0000000..28bb1e3 --- /dev/null +++ b/docs/EXAMPLES.md @@ -0,0 +1,26 @@ +# OverUB Examples + +## Event Logger Plugin +```python +from core.plugin import Plugin + +class EventLogger(Plugin): + name = "event_logger" + + async def on_message(self, event): + self.log.info("Message: %s", getattr(event, "raw_text", "")) +``` + +## Service Plugin +```python +from core.plugin import Plugin + +class WeatherService(Plugin): + name = "weather_service" + + async def on_load(self): + self.register_service("weather", self) + + async def get_weather(self, city): + return {"city": city, "status": "sunny"} +``` diff --git a/docs/GITEA_SETUP.md b/docs/GITEA_SETUP.md new file mode 100644 index 0000000..4f003d2 --- /dev/null +++ b/docs/GITEA_SETUP.md @@ -0,0 +1,73 @@ +# Gitea Setup Guide + +This project uses the `tea` CLI for repository management. + +## Login +```bash +tea login add +tea login list +tea login default +``` + +## Create Repositories +```bash +tea repos create --name overub --description "OverUB - Modular Telegram Userbot" +tea repos create --name overub-modules --description "Official OverUB Modules" +tea repos create --name overub-plugins --description "Official OverUB Plugins" +``` + +## Branch Setup +```bash +./scripts/setup-branches.sh overub +``` + +## Webhook Setup +```bash +tea webhooks create \ + --url https://your-bot-server.com/webhook/gitea \ + --events push,release,create \ + --secret your_webhook_secret +``` + +## Releases +```bash +./scripts/create-release.sh v1.0.0 +``` + +## Branch Protection & Channels +```bash +tea repos protect-branch main --enable-push --require-signed-commits +tea repos protect-branch beta --enable-push --require-signed-commits +tea repos protect-branch dev --enable-push +tea repos protect-branch lts --enable-push --require-signed-commits +``` + +## Tokens & SSH Keys +- Create a personal access token with `repo` and `read:user` scopes. +- Add SSH keys in Gitea settings and set `updates.git.use_ssh` and `updates.git.ssh_key` in `config/config.yml`. + +## Repository Layout +``` +overspend1/ + overub/ + overub-modules/ + overub-plugins/ +``` + +## Release Workflow +1. Tag and push: `git tag -a v1.0.0 -m "Release v1.0.0" && git push origin v1.0.0` +2. Create release with tea: `tea releases create --tag v1.0.0 --title "OverUB v1.0.0"` + +## Webhooks +- Use `updates.gitea.webhook_secret` in `config/config.yml`. +- Verify webhook signatures in your server endpoint. + +## CI/CD (Gitea Actions) +``` +.gitea/workflows/test.yml +``` + +## Tea Automation Scripts +- `scripts/setup-gitea.sh` +- `scripts/setup-branches.sh` +- `scripts/create-release.sh` diff --git a/docs/PLUGIN_GUIDE.md b/docs/PLUGIN_GUIDE.md new file mode 100644 index 0000000..b4a1a27 --- /dev/null +++ b/docs/PLUGIN_GUIDE.md @@ -0,0 +1,49 @@ +# OverUB Plugin Guide + +## Structure +A plugin is a Python package placed in `plugins/external/` with an `__init__.py` file. + +## Minimal Plugin +```python +from core.plugin import Plugin + +class HelloPlugin(Plugin): + name = "hello" + version = "1.0.0" + author = "you" + description = "Simple hello plugin" + + async def on_load(self): + self.log.info("Hello plugin loaded") +``` + +## Configuration Schema +```python +class MyPlugin(Plugin): + name = "my_plugin" + config_schema = { + "enabled": bool, + "settings": dict, + } +``` + +## Secrets +```python +value = self.get_secret("api_key") +``` + +## Sandbox +Use `self.context.sandbox` for safe file operations when possible. + +## Loading +Enable plugins in `config/config.yml` and place the plugin package in `plugins/external`. + +## Testing +Use `core/testing.py` as a base for plugin test scaffolding. + +## CLI +- `python -m __main__ create-plugin ` +- `python -m __main__ validate-plugin ` +- `python -m __main__ build-plugin ` +- `python -m __main__ docs-plugin ` +- `python -m __main__ test-plugin ` diff --git a/modules/__init__.py b/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/admin/API.md b/modules/admin/API.md new file mode 100644 index 0000000..11c7c06 --- /dev/null +++ b/modules/admin/API.md @@ -0,0 +1,3 @@ +# Admin API + +Public module hooks and extension points for admin. diff --git a/modules/admin/COMMANDS.md b/modules/admin/COMMANDS.md new file mode 100644 index 0000000..21af878 --- /dev/null +++ b/modules/admin/COMMANDS.md @@ -0,0 +1,15 @@ +# Admin Commands + +This module provides the core admin commands described in the main README. + +## Commands +- `.pin` - Pin a replied message +- `.modules` - Module management +- `.backup` - Backup management +- `.backup auto` - Toggle auto backups +- `.backup schedule` - Schedule backups +- `.welcome` - Welcome system +- `.stats` - Group stats +- `.modules update` - Module updates (check/all/rollback) +- `.modules update ` - Update and reload module +- `.modules update list` - List updates diff --git a/modules/admin/CONFIG.md b/modules/admin/CONFIG.md new file mode 100644 index 0000000..a11c78b --- /dev/null +++ b/modules/admin/CONFIG.md @@ -0,0 +1,3 @@ +# Admin Configuration + +Configuration options are defined in config/modules.yml under admin. diff --git a/modules/admin/README.md b/modules/admin/README.md new file mode 100644 index 0000000..e484bea --- /dev/null +++ b/modules/admin/README.md @@ -0,0 +1,3 @@ +# Admin Module + +Overview of the admin module. diff --git a/modules/admin/__init__.py b/modules/admin/__init__.py new file mode 100644 index 0000000..775454f --- /dev/null +++ b/modules/admin/__init__.py @@ -0,0 +1,306 @@ +from core.module import Module + + +class AdminModule(Module): + name = "admin" + version = "0.1.0" + description = "Admin commands" + + async def on_load(self) -> None: + builder = self.commands + await self.app.database.execute( + "CREATE TABLE IF NOT EXISTS admin_welcome (chat_id INTEGER PRIMARY KEY, message TEXT)" + ) + await self.app.database.execute( + "CREATE TABLE IF NOT EXISTS admin_stats (chat_id INTEGER PRIMARY KEY, messages INTEGER)" + ) + + async def on_message(evt): + event = evt.payload.get("event") + chat_id = getattr(event, "chat_id", None) + if not chat_id: + return + row = await self.app.database.fetchone( + "SELECT messages FROM admin_stats WHERE chat_id=?", + (chat_id,), + ) + count = (row["messages"] if row else 0) + 1 + await self.app.database.execute( + "INSERT OR REPLACE INTO admin_stats (chat_id, messages) VALUES (?, ?)", + (chat_id, count), + ) + + async def on_chat(evt): + event = evt.payload.get("event") + if not getattr(event, "user_joined", False) and not getattr(event, "user_added", False): + return + chat_id = getattr(event, "chat_id", None) + if not chat_id: + return + row = await self.app.database.fetchone( + "SELECT message FROM admin_welcome WHERE chat_id=?", + (chat_id,), + ) + if row: + await event.client.send_message(chat_id, row["message"]) + + self.app.events.on("on_message_new", on_message) + self.app.events.on("on_chat_action", on_chat) + + @builder.command( + name="pin", + description="Stub pin command", + category="admin", + usage=".pin", + ) + async def pin_cmd(event, args): + reply = await event.get_reply_message() + if not reply: + await event.reply("Reply to a message to pin") + return + await event.client.pin_message(event.chat_id, reply) + await event.reply("Pinned") + + @builder.command( + name="welcome", + description="Stub welcome setup", + category="admin", + usage=".welcome ", + ) + async def welcome_cmd(event, args): + chat_id = getattr(event, "chat_id", None) + if not chat_id: + await event.reply("Chat only") + return + if not args: + await event.reply("Usage: .welcome [message]") + return + if args[0] == "on": + message = " ".join(args[1:]) or "Welcome!" + await self.app.database.execute( + "INSERT OR REPLACE INTO admin_welcome (chat_id, message) VALUES (?, ?)", + (chat_id, message), + ) + await event.reply("Welcome enabled") + return + if args[0] == "off": + await self.app.database.execute( + "DELETE FROM admin_welcome WHERE chat_id=?", + (chat_id,), + ) + await event.reply("Welcome disabled") + return + await event.reply("Usage: .welcome [message]") + + @builder.command( + name="stats", + description="Stub group stats", + category="admin", + usage=".stats", + ) + async def stats_cmd(event, args): + chat_id = getattr(event, "chat_id", None) + if not chat_id: + await event.reply("Chat only") + return + row = await self.app.database.fetchone( + "SELECT messages FROM admin_stats WHERE chat_id=?", + (chat_id,), + ) + await event.reply(f"Messages: {row['messages'] if row else 0}") + + @builder.command( + name="modules", + description="Manage modules", + category="admin", + usage=".modules [name]", + example=".modules list", + permission="admin", + ) + async def modules_cmd(event, args): + if not args: + await event.reply("Usage: .modules [name]") + return + action = args[0] + name = args[1] if len(args) > 1 else None + module_config = self.app.config.get_modules().setdefault("modules", {}) + if action == "update": + sub = args[1] if len(args) > 1 else "check" + if sub == "check": + try: + commits = await self.app.module_updates.check_updates() + except Exception as exc: + await event.reply(f"Update check failed: {exc}") + return + await event.reply("\n".join(commits) if commits else "No module updates") + return + if sub == "list": + try: + commits = await self.app.module_updates.check_updates() + except Exception as exc: + await event.reply(f"Update list failed: {exc}") + return + await event.reply("\n".join(commits) if commits else "No module updates") + return + if sub == "all": + try: + output = await self.app.module_updates.update_all() + except Exception as exc: + self.app.update_service.record_event( + action="module_update", + status="failed", + meta={"module": "all"}, + ) + await event.reply(f"Update failed: {exc}") + return + self.app.update_service.record_event( + action="module_update", + status="success", + meta={"module": "all"}, + ) + await event.reply(output or "Modules updated") + return + if sub not in {"rollback"}: + try: + output = await self.app.module_updates.update_all() + except Exception as exc: + self.app.update_service.record_event( + action="module_update", + status="failed", + meta={"module": sub}, + ) + await event.reply(f"Update failed: {exc}") + return + await self.app.modules.reload(f"modules.{sub}") + self.app.update_service.record_event( + action="module_update", + status="success", + meta={"module": sub}, + ) + await event.reply(f"Updated {sub}") + return + if sub == "rollback": + ref = args[2] if len(args) > 2 else "HEAD~1" + try: + output = await self.app.module_updates.rollback(ref) + except Exception as exc: + self.app.update_service.record_event( + action="module_rollback", + status="failed", + meta={"ref": ref}, + ) + await event.reply(f"Rollback failed: {exc}") + return + self.app.update_service.record_event( + action="module_rollback", + status="success", + meta={"ref": ref}, + ) + await event.reply(output or "Rolled back") + return + await event.reply("Usage: .modules update [ref]") + return + if action == "list": + loaded = set(self.app.modules.list()) + summary = [] + for mod_name, cfg in module_config.items(): + status = "loaded" if f"modules.{mod_name}" in loaded else "unloaded" + enabled = "enabled" if cfg.get("enabled", True) else "disabled" + summary.append(f"{mod_name} ({enabled}, {status})") + await event.reply(", ".join(summary) if summary else "No modules configured") + return + if not name: + await event.reply("Module name required") + return + module_path = f"modules.{name}" + if action == "enable": + module_config.setdefault(name, {})["enabled"] = True + self.app.config.save_modules() + await self.app.modules.load(module_path) + await event.reply(f"Enabled {name}") + return + if action == "disable": + module_config.setdefault(name, {})["enabled"] = False + self.app.config.save_modules() + await self.app.modules.unload(module_path) + await event.reply(f"Disabled {name}") + return + if action == "reload": + await self.app.modules.reload(module_path) + await event.reply(f"Reloaded {name}") + return + if action == "info": + cfg = module_config.get(name, {}) + loaded = module_path in self.app.modules.list() + await event.reply(str({"name": name, "loaded": loaded, "config": cfg})) + return + if action == "config": + cfg = module_config.get(name, {}) + await event.reply(str(cfg)) + return + await event.reply("Unknown action") + + @builder.command( + name="backup", + description="Manage backups", + category="admin", + usage=".backup [name]", + permission="admin", + ) + async def backup_cmd(event, args): + if not args: + await event.reply("Usage: .backup [name]") + return + action = args[0] + manager = self.app.backups + if action in {"auto", "schedule"}: + if action == "auto": + toggle = args[1] if len(args) > 1 else "on" + cfg = self.app.config.get().setdefault("backup", {}) + cfg["auto"] = toggle == "on" + self.app.config.save() + await event.reply(f"Auto-backup {'enabled' if cfg['auto'] else 'disabled'}") + return + if action == "schedule": + if len(args) < 2: + await event.reply("Time required") + return + cfg = self.app.config.get().setdefault("backup", {}) + cfg["schedule"] = args[1] + self.app.config.save() + await event.reply("Backup schedule updated") + return + if len(args) < 2: + await event.reply("Usage: .backup [name]") + return + scope = args[1] + if action == "create": + try: + path = manager.create(scope) + except Exception as exc: + self.app.update_service.record_event( + action="backup", + status="failed", + meta={"scope": scope}, + ) + await event.reply(f"Backup failed: {exc}") + return + self.app.update_service.record_event( + action="backup", + status="success", + meta={"scope": scope, "name": path.name}, + ) + await event.reply(f"Backup created: {path.name}") + return + if action == "list": + items = manager.list(scope) + await event.reply(", ".join(items) if items else "No backups found") + return + if action == "delete": + if len(args) < 3: + await event.reply("Backup name required") + return + manager.delete(scope, args[2]) + await event.reply("Backup deleted") + return + await event.reply("Unknown action") diff --git a/modules/admin/analytics.py b/modules/admin/analytics.py new file mode 100644 index 0000000..60f247c --- /dev/null +++ b/modules/admin/analytics.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class AnalyticsModule(Module): + name = "admin.analytics" + version = "0.1.0" + description = "Analytics tools" + + async def on_load(self) -> None: + return None diff --git a/modules/admin/backup.py b/modules/admin/backup.py new file mode 100644 index 0000000..2fab909 --- /dev/null +++ b/modules/admin/backup.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class BackupModule(Module): + name = "admin.backup" + version = "0.1.0" + description = "Backup tools" + + async def on_load(self) -> None: + return None diff --git a/modules/admin/moderation.py b/modules/admin/moderation.py new file mode 100644 index 0000000..9e1b5f4 --- /dev/null +++ b/modules/admin/moderation.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class ModerationModule(Module): + name = "admin.moderation" + version = "0.1.0" + description = "Moderation tools" + + async def on_load(self) -> None: + return None diff --git a/modules/admin/welcome.py b/modules/admin/welcome.py new file mode 100644 index 0000000..15b1fb4 --- /dev/null +++ b/modules/admin/welcome.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class WelcomeModule(Module): + name = "admin.welcome" + version = "0.1.0" + description = "Welcome tools" + + async def on_load(self) -> None: + return None diff --git a/modules/automation/API.md b/modules/automation/API.md new file mode 100644 index 0000000..e0bcc38 --- /dev/null +++ b/modules/automation/API.md @@ -0,0 +1,3 @@ +# Automation API + +Public module hooks and extension points for automation. diff --git a/modules/automation/COMMANDS.md b/modules/automation/COMMANDS.md new file mode 100644 index 0000000..b9e3c66 --- /dev/null +++ b/modules/automation/COMMANDS.md @@ -0,0 +1,9 @@ +# Automation Commands + +This module provides the core automation commands described in the main README. + +## Commands +- `.schedule` +- `.afk` +- `.autoreply` +- `.autoforward` diff --git a/modules/automation/CONFIG.md b/modules/automation/CONFIG.md new file mode 100644 index 0000000..af0939d --- /dev/null +++ b/modules/automation/CONFIG.md @@ -0,0 +1,3 @@ +# Automation Configuration + +Configuration options are defined in config/modules.yml under automation. diff --git a/modules/automation/README.md b/modules/automation/README.md new file mode 100644 index 0000000..dbfdf16 --- /dev/null +++ b/modules/automation/README.md @@ -0,0 +1,3 @@ +# Automation Module + +Overview of the automation module. diff --git a/modules/automation/__init__.py b/modules/automation/__init__.py new file mode 100644 index 0000000..704d41d --- /dev/null +++ b/modules/automation/__init__.py @@ -0,0 +1,118 @@ +import asyncio + +from core.module import Module + + +class AutomationModule(Module): + name = "automation" + version = "0.1.0" + description = "Automation commands" + + async def on_load(self) -> None: + builder = self.commands + self._autoreply_enabled = False + self._autoreply_text = "" + self._afk_enabled = False + self._afk_reason = "" + self._forward_enabled = False + self._forward_target = None + + async def on_message(evt): + if not self._autoreply_enabled: + return + message = evt.payload.get("message") + event = evt.payload.get("event") + if getattr(event, "out", False): + return + if message and hasattr(message, "reply"): + await message.reply(self._autoreply_text) + + if self._afk_enabled and message and hasattr(message, "reply"): + await message.reply(f"AFK: {self._afk_reason}") + + if self._forward_enabled and message and self._forward_target: + try: + await event.client.forward_messages(self._forward_target, message) + except Exception: + self.log.exception("Auto-forward failed") + + self.app.events.on("on_message_new", on_message) + + @builder.command( + name="afk", + description="Stub AFK", + category="automation", + usage=".afk ", + ) + async def afk_cmd(event, args): + if args: + self._afk_enabled = True + self._afk_reason = " ".join(args) + await event.reply("AFK enabled") + else: + self._afk_enabled = False + self._afk_reason = "" + await event.reply("AFK disabled") + + @builder.command( + name="schedule", + description="Schedule a message in seconds", + category="automation", + usage=".schedule ", + ) + async def schedule_cmd(event, args): + if len(args) < 2: + await event.reply("Usage: .schedule ") + return + try: + seconds = int(args[0]) + except ValueError: + await event.reply("Invalid seconds") + return + text = " ".join(args[1:]) + await event.reply(f"Scheduled in {seconds}s") + await asyncio.sleep(seconds) + await event.reply(text) + + @builder.command( + name="autoreply", + description="Toggle auto-reply", + category="automation", + usage=".autoreply [text]", + ) + async def autoreply_cmd(event, args): + if not args: + await event.reply("Usage: .autoreply [text]") + return + if args[0] == "on": + self._autoreply_enabled = True + self._autoreply_text = " ".join(args[1:]) or "Auto-reply enabled" + await event.reply("Auto-reply enabled") + return + if args[0] == "off": + self._autoreply_enabled = False + await event.reply("Auto-reply disabled") + return + await event.reply("Usage: .autoreply [text]") + + @builder.command( + name="autoforward", + description="Stub auto-forward", + category="automation", + usage=".autoforward", + ) + async def autoforward_cmd(event, args): + if not args: + await event.reply("Usage: .autoforward ") + return + if args[0] == "on" and len(args) > 1: + self._forward_enabled = True + self._forward_target = int(args[1]) + await event.reply("Auto-forward enabled") + return + if args[0] == "off": + self._forward_enabled = False + self._forward_target = None + await event.reply("Auto-forward disabled") + return + await event.reply("Usage: .autoforward ") diff --git a/modules/automation/afk.py b/modules/automation/afk.py new file mode 100644 index 0000000..db0841c --- /dev/null +++ b/modules/automation/afk.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class AfkModule(Module): + name = "automation.afk" + version = "0.1.0" + description = "AFK tools" + + async def on_load(self) -> None: + return None diff --git a/modules/automation/auto_reply.py b/modules/automation/auto_reply.py new file mode 100644 index 0000000..c37c2a8 --- /dev/null +++ b/modules/automation/auto_reply.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class AutoReplyModule(Module): + name = "automation.auto_reply" + version = "0.1.0" + description = "Auto-reply tools" + + async def on_load(self) -> None: + return None diff --git a/modules/automation/forwarding.py b/modules/automation/forwarding.py new file mode 100644 index 0000000..22e97c1 --- /dev/null +++ b/modules/automation/forwarding.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class ForwardingModule(Module): + name = "automation.forwarding" + version = "0.1.0" + description = "Forwarding tools" + + async def on_load(self) -> None: + return None diff --git a/modules/automation/scheduler.py b/modules/automation/scheduler.py new file mode 100644 index 0000000..f835192 --- /dev/null +++ b/modules/automation/scheduler.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class SchedulerModule(Module): + name = "automation.scheduler" + version = "0.1.0" + description = "Scheduler tools" + + async def on_load(self) -> None: + return None diff --git a/modules/developer/API.md b/modules/developer/API.md new file mode 100644 index 0000000..bb21302 --- /dev/null +++ b/modules/developer/API.md @@ -0,0 +1,3 @@ +# Developer API + +Public module hooks and extension points for developer. diff --git a/modules/developer/COMMANDS.md b/modules/developer/COMMANDS.md new file mode 100644 index 0000000..3f70697 --- /dev/null +++ b/modules/developer/COMMANDS.md @@ -0,0 +1,17 @@ +# Developer Commands + +This module provides the core developer commands described in the main README. + +## Commands +- `.exec` - Execute Python code (restricted) +- `.eval` - Evaluate Python expression (restricted) +- `.format` - Format JSON +- `.regex` - Regex test +- `.uuid` - UUID generator +- `.update` - Core update operations +- `.perf` - Performance stats +- `.version` - Version info +- `.changelog` - Git changelog (full, search, since, between, unreleased) +- `.config` - Reload config +- `.logs` - View logs +- `.migrate` - Migration operations diff --git a/modules/developer/CONFIG.md b/modules/developer/CONFIG.md new file mode 100644 index 0000000..b9fd524 --- /dev/null +++ b/modules/developer/CONFIG.md @@ -0,0 +1,3 @@ +# Developer Configuration + +Configuration options are defined in config/modules.yml under developer. diff --git a/modules/developer/README.md b/modules/developer/README.md new file mode 100644 index 0000000..288ad07 --- /dev/null +++ b/modules/developer/README.md @@ -0,0 +1,3 @@ +# Developer Module + +Overview of the developer module. diff --git a/modules/developer/__init__.py b/modules/developer/__init__.py new file mode 100644 index 0000000..1adde11 --- /dev/null +++ b/modules/developer/__init__.py @@ -0,0 +1,331 @@ +from core.module import Module + + +class DeveloperModule(Module): + name = "developer" + version = "0.1.0" + description = "Developer commands" + + async def on_load(self) -> None: + builder = self.commands + + @builder.command( + name="eval", + description="Stub eval command", + category="developer", + usage=".eval ", + permission="admin", + ) + async def eval_cmd(event, args): + expr = " ".join(args) + if not expr: + await event.reply("Expression required") + return + scope = {"app": self.app} + try: + result = eval(expr, {"__builtins__": {}}, scope) + except Exception as exc: + await event.reply(f"Eval error: {exc}") + return + await event.reply(str(result)) + + @builder.command( + name="exec", + description="Stub exec command", + category="developer", + usage=".exec ", + permission="admin", + ) + async def exec_cmd(event, args): + code = " ".join(args) + if not code: + await event.reply("Code required") + return + scope = {"app": self.app} + try: + exec(code, {"__builtins__": {}}, scope) + except Exception as exc: + await event.reply(f"Exec error: {exc}") + return + await event.reply("Executed") + + @builder.command( + name="format", + description="Format JSON", + category="developer", + usage=".format ", + permission="admin", + ) + async def format_cmd(event, args): + import json + + text = " ".join(args) + try: + data = json.loads(text) + except Exception: + await event.reply("Invalid JSON") + return + await event.reply(json.dumps(data, indent=2, sort_keys=True)) + + @builder.command( + name="regex", + description="Test regex pattern", + category="developer", + usage=".regex ", + permission="admin", + ) + async def regex_cmd(event, args): + import re + + if len(args) < 2: + await event.reply("Usage: .regex ") + return + pattern = args[0] + text = " ".join(args[1:]) + matches = re.findall(pattern, text) + await event.reply(f"Matches: {len(matches)}") + + @builder.command( + name="uuid", + description="Generate UUID4", + category="developer", + usage=".uuid", + permission="admin", + ) + async def uuid_cmd(event, args): + import uuid + + await event.reply(str(uuid.uuid4())) + + @builder.command( + name="update", + description="Core update commands", + category="developer", + usage=".update ", + permission="admin", + ) + async def update_cmd(event, args): + if not args: + await event.reply("Usage: .update ") + return + action = args[0] + manager = self.app.updater + if action == "check": + commits = await manager.check_updates() + await event.reply("\n".join(commits) if commits else "No updates") + return + if action == "now": + output = await self.app.update_service.apply_updates() + await event.reply(output or "Updated") + return + if action == "info": + info = await manager.get_version_info() + await event.reply(str(info)) + return + if action == "remote": + remote = await manager.get_remote_url() + await event.reply(remote) + return + if action == "fetch": + output = await manager.fetch_updates() + await event.reply(output or "Fetched") + return + if action == "release": + if len(args) < 3: + await event.reply("Usage: .update release ") + return + owner, repo = args[1].split("/", 1) + tag = args[2] + info = await manager.download_release(owner, repo, tag) + asset_url = info.get("asset_url") + if not asset_url: + await event.reply("No assets found") + return + dest = self.app.root / "data" / "releases" / f"{repo}-{tag}.asset" + path = await manager.download_release_asset(asset_url, dest) + await event.reply(f"Downloaded {path.name}") + return + if action == "rollback": + if len(args) < 2: + await event.reply("Commit required") + return + output = await self.app.update_service.rollback(args[1]) + await event.reply(output) + return + if action == "channel": + if len(args) < 2: + await event.reply("Branch required") + return + manager.branch = args[1] + await event.reply(f"Channel set to {manager.branch}") + return + if action == "changelog": + ref = args[1] if len(args) > 1 else "HEAD" + output = await manager.get_changelog(ref) + await event.reply(output) + return + if action == "history": + path = self.app.root / "data" / "update_history.jsonl" + if not path.exists(): + await event.reply("No history") + return + await event.reply(path.read_text(encoding="utf-8")[-3500:]) + return + if action == "stats": + await event.reply(self.app.update_service.dashboard()) + return + if action in {"emergency", "force", "critical"}: + try: + output = await self.app.update_service.apply_updates() + except Exception as exc: + await event.reply(f"Update failed: {exc}") + return + await event.reply(output or "Updated") + return + await event.reply("Unknown action") + + @builder.command( + name="perf", + description="Show performance stats", + category="developer", + usage=".perf", + permission="admin", + ) + async def perf_cmd(event, args): + from core.monitor import get_system_stats + + stats = get_system_stats() + await event.reply(str(stats)) + + @builder.command( + name="version", + description="Show version info", + category="developer", + usage=".version [core|modules|plugins]", + permission="admin", + ) + async def version_cmd(event, args): + report = await self.app.versions.get_report() + if args and args[0] == "modules": + await event.reply(str(report.modules)) + return + if args and args[0] == "plugins": + await event.reply(str(report.plugins)) + return + await event.reply(str(report)) + + @builder.command( + name="changelog", + description="Show changelog", + category="developer", + usage=".changelog [ref|full|search|since|between|unreleased]", + permission="admin", + ) + async def changelog_cmd(event, args): + if not args: + output = await self.app.updater.get_changelog("HEAD") + await event.reply(output) + return + action = args[0] + if action == "full": + output = await self.app.updater.get_changelog("HEAD") + await event.reply(output) + return + if action == "search": + if len(args) < 2: + await event.reply("Usage: .changelog search ") + return + output = await self.app.updater.search_commits(" ".join(args[1:])) + await event.reply(output) + return + if action == "since": + if len(args) < 2: + await event.reply("Usage: .changelog since ") + return + output = await self.app.updater.get_changelog_since(args[1]) + await event.reply(output) + return + if action == "between": + if len(args) < 3: + await event.reply("Usage: .changelog between ") + return + output = await self.app.updater.get_changelog_between(args[1], args[2]) + await event.reply(output) + return + if action == "unreleased": + output = await self.app.updater.get_unreleased_changelog() + await event.reply(output) + return + output = await self.app.updater.get_changelog(action) + await event.reply(output) + + @builder.command( + name="config", + description="Reload configuration", + category="developer", + usage=".config reload", + permission="admin", + ) + async def config_cmd(event, args): + if not args or args[0] != "reload": + await event.reply("Usage: .config reload") + return + self.app.config.reload() + await event.reply("Config reloaded") + + @builder.command( + name="logs", + description="Show recent logs", + category="developer", + usage=".logs [module]", + permission="admin", + ) + async def logs_cmd(event, args): + import pathlib + log_dir = pathlib.Path("data/logs") + if args: + log_file = log_dir / "modules" / f"{args[0].replace('.', '_')}.log" + else: + log_file = log_dir / "overub.log" + if not log_file.exists(): + await event.reply("Log not found") + return + text = log_file.read_text(encoding="utf-8") + await event.reply(text[-3500:]) + + @builder.command( + name="migrate", + description="Manage migrations", + category="developer", + usage=".migrate [name|steps]", + permission="admin", + ) + async def migrate_cmd(event, args): + if not args: + await event.reply("Usage: .migrate [name|steps]") + return + action = args[0] + if action == "list": + items = self.app.migrations.list_migrations() + await event.reply("\n".join(items) if items else "No migrations") + return + if action == "validate": + errors = self.app.migrations.validate() + await event.reply("\n".join(errors) if errors else "Migrations OK") + return + if action == "apply": + await self.app.migrations.apply(self.app) + await event.reply("Migrations applied") + return + if action == "rollback": + name = None + steps = 1 + if len(args) > 1: + if args[1].isdigit(): + steps = int(args[1]) + else: + name = args[1] + rolled = await self.app.migrations.rollback(self.app, name=name, steps=steps) + await event.reply("Rolled back: " + ", ".join(rolled) if rolled else "Nothing to roll back") + return + await event.reply("Unknown action") diff --git a/modules/developer/api.py b/modules/developer/api.py new file mode 100644 index 0000000..b2bef5e --- /dev/null +++ b/modules/developer/api.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class ApiModule(Module): + name = "developer.api" + version = "0.1.0" + description = "API tools" + + async def on_load(self) -> None: + return None diff --git a/modules/developer/code.py b/modules/developer/code.py new file mode 100644 index 0000000..3a4f421 --- /dev/null +++ b/modules/developer/code.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class CodeModule(Module): + name = "developer.code" + version = "0.1.0" + description = "Code tools" + + async def on_load(self) -> None: + return None diff --git a/modules/developer/debug.py b/modules/developer/debug.py new file mode 100644 index 0000000..5ea9110 --- /dev/null +++ b/modules/developer/debug.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class DebugModule(Module): + name = "developer.debug" + version = "0.1.0" + description = "Debug tools" + + async def on_load(self) -> None: + return None diff --git a/modules/fun/API.md b/modules/fun/API.md new file mode 100644 index 0000000..13faa46 --- /dev/null +++ b/modules/fun/API.md @@ -0,0 +1,3 @@ +# Fun API + +Public module hooks and extension points for fun. diff --git a/modules/fun/COMMANDS.md b/modules/fun/COMMANDS.md new file mode 100644 index 0000000..245e929 --- /dev/null +++ b/modules/fun/COMMANDS.md @@ -0,0 +1,10 @@ +# Fun Commands + +This module provides the core fun commands described in the main README. + +## Commands +- `.dice` +- `.joke` +- `.8ball` +- `.rps` +- `.trivia` diff --git a/modules/fun/CONFIG.md b/modules/fun/CONFIG.md new file mode 100644 index 0000000..3540929 --- /dev/null +++ b/modules/fun/CONFIG.md @@ -0,0 +1,3 @@ +# Fun Configuration + +Configuration options are defined in config/modules.yml under fun. diff --git a/modules/fun/README.md b/modules/fun/README.md new file mode 100644 index 0000000..1c95f36 --- /dev/null +++ b/modules/fun/README.md @@ -0,0 +1,3 @@ +# Fun Module + +Overview of the fun module. diff --git a/modules/fun/__init__.py b/modules/fun/__init__.py new file mode 100644 index 0000000..cb1275d --- /dev/null +++ b/modules/fun/__init__.py @@ -0,0 +1,81 @@ +import random + +from core.module import Module + + +class FunModule(Module): + name = "fun" + version = "0.1.0" + description = "Fun commands" + + async def on_load(self) -> None: + builder = self.commands + + @builder.command( + name="dice", + description="Roll a dice", + category="fun", + usage=".dice", + ) + async def dice_cmd(event, args): + await event.reply(f"You rolled {random.randint(1, 6)}") + + @builder.command( + name="joke", + description="Tell a joke", + category="fun", + usage=".joke", + ) + async def joke_cmd(event, args): + jokes = [ + "Why do programmers prefer dark mode? Because light attracts bugs.", + "There are 10 types of people in the world: those who understand binary and those who don't.", + "I would tell you a UDP joke, but you might not get it.", + ] + await event.reply(random.choice(jokes)) + + @builder.command( + name="8ball", + description="Magic 8-ball", + category="fun", + usage=".8ball ", + ) + async def eightball_cmd(event, args): + answers = [ + "Yes.", + "No.", + "Maybe.", + "Ask again later.", + "Definitely.", + ] + await event.reply(random.choice(answers)) + + @builder.command( + name="rps", + description="Rock paper scissors", + category="fun", + usage=".rps ", + ) + async def rps_cmd(event, args): + choices = ["rock", "paper", "scissors"] + user = args[0].lower() if args else "" + if user not in choices: + await event.reply("Choose rock, paper, or scissors") + return + bot = random.choice(choices) + await event.reply(f"You: {user} | Bot: {bot}") + + @builder.command( + name="trivia", + description="Trivia question", + category="fun", + usage=".trivia", + ) + async def trivia_cmd(event, args): + trivia = [ + ("What is the capital of France?", "Paris"), + ("2 + 2 = ?", "4"), + ("What language is OverUB written in?", "Python"), + ] + question, answer = random.choice(trivia) + await event.reply(f"{question}\nAnswer: {answer}") diff --git a/modules/fun/games.py b/modules/fun/games.py new file mode 100644 index 0000000..7d2ed04 --- /dev/null +++ b/modules/fun/games.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class GamesModule(Module): + name = "fun.games" + version = "0.1.0" + description = "Games tools" + + async def on_load(self) -> None: + return None diff --git a/modules/fun/jokes.py b/modules/fun/jokes.py new file mode 100644 index 0000000..8964511 --- /dev/null +++ b/modules/fun/jokes.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class JokesModule(Module): + name = "fun.jokes" + version = "0.1.0" + description = "Jokes tools" + + async def on_load(self) -> None: + return None diff --git a/modules/fun/random.py b/modules/fun/random.py new file mode 100644 index 0000000..a5d83c4 --- /dev/null +++ b/modules/fun/random.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class RandomModule(Module): + name = "fun.random" + version = "0.1.0" + description = "Random tools" + + async def on_load(self) -> None: + return None diff --git a/modules/media/API.md b/modules/media/API.md new file mode 100644 index 0000000..4bcba32 --- /dev/null +++ b/modules/media/API.md @@ -0,0 +1,3 @@ +# Media API + +Public module hooks and extension points for media. diff --git a/modules/media/COMMANDS.md b/modules/media/COMMANDS.md new file mode 100644 index 0000000..675298d --- /dev/null +++ b/modules/media/COMMANDS.md @@ -0,0 +1,11 @@ +# Media Commands + +This module provides the core media commands described in the main README. + +## Commands +- `.dl` +- `.convert` +- `.resize` +- `.compress` +- `.sticker` +- `.gif` diff --git a/modules/media/CONFIG.md b/modules/media/CONFIG.md new file mode 100644 index 0000000..863a7ac --- /dev/null +++ b/modules/media/CONFIG.md @@ -0,0 +1,3 @@ +# Media Configuration + +Configuration options are defined in config/modules.yml under media. diff --git a/modules/media/README.md b/modules/media/README.md new file mode 100644 index 0000000..d70a23a --- /dev/null +++ b/modules/media/README.md @@ -0,0 +1,3 @@ +# Media Module + +Overview of the media module. diff --git a/modules/media/__init__.py b/modules/media/__init__.py new file mode 100644 index 0000000..23a6c9f --- /dev/null +++ b/modules/media/__init__.py @@ -0,0 +1,141 @@ +from core.module import Module + + +class MediaModule(Module): + name = "media" + version = "0.1.0" + description = "Media handling commands" + optional_dependencies = ["lib:Pillow"] + + async def on_load(self) -> None: + builder = self.commands + + @builder.command( + name="dl", + description="Stub download command", + category="media", + usage=".dl ", + example=".dl https://...", + ) + async def download_cmd(event, args): + message = getattr(event, "message", None) + if not message or not getattr(message, "reply_to_msg_id", None): + await event.reply("Reply to media to download") + return + reply = await event.get_reply_message() + if not reply or not reply.media: + await event.reply("No media found") + return + settings = self.get_config().get("settings", {}) + download_path = settings.get("download_path", "data/downloads") + path = await event.client.download_media(reply, file=download_path) + await event.reply(f"Downloaded: {path}") + + @builder.command( + name="convert", + description="Stub media convert", + category="media", + usage=".convert ", + ) + async def convert_cmd(event, args): + try: + from PIL import Image + except ImportError: + await event.reply("Pillow not installed") + return + if not args: + await event.reply("Format required") + return + fmt = args[0].upper() + reply = await event.get_reply_message() + if not reply or not reply.media: + await event.reply("Reply to an image") + return + temp = await event.client.download_media(reply) + img = Image.open(temp) + output = f"{temp}.{fmt.lower()}" + img.save(output, fmt) + await event.reply(file=output) + + @builder.command( + name="resize", + description="Stub media resize", + category="media", + usage=".resize ", + ) + async def resize_cmd(event, args): + try: + from PIL import Image + except ImportError: + await event.reply("Pillow not installed") + return + if len(args) < 2: + await event.reply("Usage: .resize ") + return + reply = await event.get_reply_message() + if not reply or not reply.media: + await event.reply("Reply to an image") + return + temp = await event.client.download_media(reply) + img = Image.open(temp) + img = img.resize((int(args[0]), int(args[1]))) + output = f"{temp}_resized.png" + img.save(output, "PNG") + await event.reply(file=output) + + @builder.command( + name="compress", + description="Stub media compression", + category="media", + usage=".compress", + ) + async def compress_cmd(event, args): + try: + from PIL import Image + except ImportError: + await event.reply("Pillow not installed") + return + reply = await event.get_reply_message() + if not reply or not reply.media: + await event.reply("Reply to an image") + return + temp = await event.client.download_media(reply) + img = Image.open(temp) + output = f"{temp}_compressed.jpg" + img.save(output, "JPEG", quality=60) + await event.reply(file=output) + + @builder.command( + name="sticker", + description="Stub sticker tools", + category="media", + usage=".sticker", + ) + async def sticker_cmd(event, args): + try: + from PIL import Image + except ImportError: + await event.reply("Pillow not installed") + return + reply = await event.get_reply_message() + if not reply or not reply.media: + await event.reply("Reply to an image") + return + temp = await event.client.download_media(reply) + img = Image.open(temp).convert("RGBA") + output = f"{temp}.webp" + img.save(output, "WEBP") + await event.reply(file=output) + + @builder.command( + name="gif", + description="Stub GIF tools", + category="media", + usage=".gif", + ) + async def gif_cmd(event, args): + reply = await event.get_reply_message() + if not reply or not reply.media: + await event.reply("Reply to a video or animation") + return + await event.reply(file=reply, force_document=False) diff --git a/modules/media/compress.py b/modules/media/compress.py new file mode 100644 index 0000000..6f0770b --- /dev/null +++ b/modules/media/compress.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class CompressModule(Module): + name = "media.compress" + version = "0.1.0" + description = "Media compression tools" + + async def on_load(self) -> None: + return None diff --git a/modules/media/convert.py b/modules/media/convert.py new file mode 100644 index 0000000..1024918 --- /dev/null +++ b/modules/media/convert.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class ConvertModule(Module): + name = "media.convert" + version = "0.1.0" + description = "Media conversion tools" + + async def on_load(self) -> None: + return None diff --git a/modules/media/download.py b/modules/media/download.py new file mode 100644 index 0000000..9624dc0 --- /dev/null +++ b/modules/media/download.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class DownloadModule(Module): + name = "media.download" + version = "0.1.0" + description = "Media download tools" + + async def on_load(self) -> None: + return None diff --git a/modules/media/edit.py b/modules/media/edit.py new file mode 100644 index 0000000..f119b94 --- /dev/null +++ b/modules/media/edit.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class EditModule(Module): + name = "media.edit" + version = "0.1.0" + description = "Media edit tools" + + async def on_load(self) -> None: + return None diff --git a/modules/media/stickers.py b/modules/media/stickers.py new file mode 100644 index 0000000..3be4cad --- /dev/null +++ b/modules/media/stickers.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class StickersModule(Module): + name = "media.stickers" + version = "0.1.0" + description = "Sticker tools" + + async def on_load(self) -> None: + return None diff --git a/modules/search/API.md b/modules/search/API.md new file mode 100644 index 0000000..0292917 --- /dev/null +++ b/modules/search/API.md @@ -0,0 +1,3 @@ +# Search API + +Public module hooks and extension points for search. diff --git a/modules/search/COMMANDS.md b/modules/search/COMMANDS.md new file mode 100644 index 0000000..f00d2a7 --- /dev/null +++ b/modules/search/COMMANDS.md @@ -0,0 +1,10 @@ +# Search Commands + +This module provides the core search commands described in the main README. + +## Commands +- `.google` +- `.wiki` +- `.yt` +- `.translate` +- `.define` diff --git a/modules/search/CONFIG.md b/modules/search/CONFIG.md new file mode 100644 index 0000000..64702ce --- /dev/null +++ b/modules/search/CONFIG.md @@ -0,0 +1,3 @@ +# Search Configuration + +Configuration options are defined in config/modules.yml under search. diff --git a/modules/search/README.md b/modules/search/README.md new file mode 100644 index 0000000..e8d0d15 --- /dev/null +++ b/modules/search/README.md @@ -0,0 +1,3 @@ +# Search Module + +Overview of the search module. diff --git a/modules/search/__init__.py b/modules/search/__init__.py new file mode 100644 index 0000000..46c5ad9 --- /dev/null +++ b/modules/search/__init__.py @@ -0,0 +1,111 @@ +from core.module import Module + + +class SearchModule(Module): + name = "search" + version = "0.1.0" + description = "Search commands" + + async def on_load(self) -> None: + builder = self.commands + + @builder.command( + name="google", + description="Stub web search", + category="search", + usage=".google ", + example=".google overub", + ) + async def google_cmd(event, args): + query = " ".join(args) + if not query: + await event.reply("Query required") + return + url = f"https://api.duckduckgo.com/?q={query}&format=json&no_redirect=1" + data = await self.app.http.get_json(url) + if not data: + await event.reply("Search unavailable") + return + text = data.get("AbstractText") or data.get("Heading") or "No result" + await event.reply(text) + + @builder.command( + name="wiki", + description="Stub wiki search", + category="search", + usage=".wiki ", + ) + async def wiki_cmd(event, args): + query = " ".join(args) + if not query: + await event.reply("Query required") + return + url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{query}" + data = await self.app.http.get_json(url) + if not data: + await event.reply("Wiki unavailable") + return + extract = data.get("extract") + await event.reply(extract or "No result") + + @builder.command( + name="yt", + description="Stub YouTube search", + category="search", + usage=".yt ", + ) + async def yt_cmd(event, args): + query = " ".join(args) + key = self.app.config.get().get("services", {}).get("youtube_api_key") + if not key: + await event.reply("YouTube API key missing") + return + url = ( + "https://www.googleapis.com/youtube/v3/search" + f"?part=snippet&q={query}&maxResults=1&key={key}" + ) + data = await self.app.http.get_json(url) + if not data or not data.get("items"): + await event.reply("No results") + return + item = data["items"][0] + title = item["snippet"]["title"] + await event.reply(title) + + @builder.command( + name="translate", + description="Stub translation", + category="search", + usage=".translate ", + ) + async def translate_cmd(event, args): + text = " ".join(args) + url = self.app.config.get().get("services", {}).get("translate_url") + if not url: + await event.reply("Translate URL missing") + return + payload_url = f"{url}?q={text}" + data = await self.app.http.get_json(payload_url) + if not data: + await event.reply("Translation unavailable") + return + await event.reply(str(data)) + + @builder.command( + name="define", + description="Stub dictionary", + category="search", + usage=".define ", + ) + async def define_cmd(event, args): + word = args[0] if args else "" + if not word: + await event.reply("Word required") + return + url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" + data = await self.app.http.get_json(url) + if not data or isinstance(data, dict) and data.get("title"): + await event.reply("Definition not found") + return + meaning = data[0]["meanings"][0]["definitions"][0]["definition"] + await event.reply(meaning) diff --git a/modules/search/dictionary.py b/modules/search/dictionary.py new file mode 100644 index 0000000..5f4c08b --- /dev/null +++ b/modules/search/dictionary.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class DictionaryModule(Module): + name = "search.dictionary" + version = "0.1.0" + description = "Dictionary tools" + + async def on_load(self) -> None: + return None diff --git a/modules/search/media.py b/modules/search/media.py new file mode 100644 index 0000000..9976c6e --- /dev/null +++ b/modules/search/media.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class MediaSearchModule(Module): + name = "search.media" + version = "0.1.0" + description = "Media search tools" + + async def on_load(self) -> None: + return None diff --git a/modules/search/translate.py b/modules/search/translate.py new file mode 100644 index 0000000..6bd08f9 --- /dev/null +++ b/modules/search/translate.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class TranslateModule(Module): + name = "search.translate" + version = "0.1.0" + description = "Translation tools" + + async def on_load(self) -> None: + return None diff --git a/modules/search/web.py b/modules/search/web.py new file mode 100644 index 0000000..e054428 --- /dev/null +++ b/modules/search/web.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class WebSearchModule(Module): + name = "search.web" + version = "0.1.0" + description = "Web search tools" + + async def on_load(self) -> None: + return None diff --git a/modules/text/API.md b/modules/text/API.md new file mode 100644 index 0000000..958f250 --- /dev/null +++ b/modules/text/API.md @@ -0,0 +1,3 @@ +# Text API + +Public module hooks and extension points for text. diff --git a/modules/text/COMMANDS.md b/modules/text/COMMANDS.md new file mode 100644 index 0000000..730f18e --- /dev/null +++ b/modules/text/COMMANDS.md @@ -0,0 +1,14 @@ +# Text Commands + +This module provides the core text commands described in the main README. + +## Commands +- `.bold` +- `.italic` +- `.reverse` +- `.upper` +- `.lower` +- `.encode` +- `.decode` +- `.count` +- `.fancy` diff --git a/modules/text/CONFIG.md b/modules/text/CONFIG.md new file mode 100644 index 0000000..4f706e8 --- /dev/null +++ b/modules/text/CONFIG.md @@ -0,0 +1,3 @@ +# Text Configuration + +Configuration options are defined in config/modules.yml under text. diff --git a/modules/text/README.md b/modules/text/README.md new file mode 100644 index 0000000..9e25a76 --- /dev/null +++ b/modules/text/README.md @@ -0,0 +1,3 @@ +# Text Module + +Overview of the text module. diff --git a/modules/text/__init__.py b/modules/text/__init__.py new file mode 100644 index 0000000..2529d10 --- /dev/null +++ b/modules/text/__init__.py @@ -0,0 +1,123 @@ +from core.module import Module + + +class TextModule(Module): + name = "text" + version = "0.1.0" + description = "Text manipulation commands" + + async def on_load(self) -> None: + builder = self.commands + + @builder.command( + name="upper", + description="Convert text to uppercase", + category="text", + usage=".upper ", + example=".upper hello", + ) + async def upper_cmd(event, args): + text = " ".join(args) + await event.reply(text.upper() if text else "") + + @builder.command( + name="lower", + description="Convert text to lowercase", + category="text", + usage=".lower ", + example=".lower HELLO", + ) + async def lower_cmd(event, args): + text = " ".join(args) + await event.reply(text.lower() if text else "") + + @builder.command( + name="bold", + description="Wrap text in bold markdown", + category="text", + usage=".bold ", + ) + async def bold_cmd(event, args): + text = " ".join(args) + await event.reply(f"**{text}**" if text else "") + + @builder.command( + name="italic", + description="Wrap text in italic markdown", + category="text", + usage=".italic ", + ) + async def italic_cmd(event, args): + text = " ".join(args) + await event.reply(f"_{text}_" if text else "") + + @builder.command( + name="reverse", + description="Reverse text", + category="text", + usage=".reverse ", + ) + async def reverse_cmd(event, args): + text = " ".join(args) + await event.reply(text[::-1] if text else "") + + @builder.command( + name="encode", + description="Base64 encode text", + category="text", + usage=".encode ", + ) + async def encode_cmd(event, args): + import base64 + + text = " ".join(args) + if not text: + await event.reply("") + return + encoded = base64.b64encode(text.encode("utf-8")).decode("utf-8") + await event.reply(encoded) + + @builder.command( + name="decode", + description="Base64 decode text", + category="text", + usage=".decode ", + ) + async def decode_cmd(event, args): + import base64 + + text = " ".join(args) + if not text: + await event.reply("") + return + try: + decoded = base64.b64decode(text.encode("utf-8")).decode("utf-8") + except Exception: + await event.reply("Invalid base64") + return + await event.reply(decoded) + + @builder.command( + name="count", + description="Count characters and words", + category="text", + usage=".count ", + ) + async def count_cmd(event, args): + text = " ".join(args) + if not text: + await event.reply("Chars: 0 Words: 0") + return + words = len(text.split()) + chars = len(text) + await event.reply(f"Chars: {chars} Words: {words}") + + @builder.command( + name="fancy", + description="Simple spaced text", + category="text", + usage=".fancy ", + ) + async def fancy_cmd(event, args): + text = " ".join(args) + await event.reply(" ".join(text) if text else "") diff --git a/modules/text/analysis.py b/modules/text/analysis.py new file mode 100644 index 0000000..dc027c1 --- /dev/null +++ b/modules/text/analysis.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class AnalysisModule(Module): + name = "text.analysis" + version = "0.1.0" + description = "Text analysis tools" + + async def on_load(self) -> None: + return None diff --git a/modules/text/conversion.py b/modules/text/conversion.py new file mode 100644 index 0000000..17860b3 --- /dev/null +++ b/modules/text/conversion.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class ConversionModule(Module): + name = "text.conversion" + version = "0.1.0" + description = "Text conversion tools" + + async def on_load(self) -> None: + return None diff --git a/modules/text/encoding.py b/modules/text/encoding.py new file mode 100644 index 0000000..4c6e0c6 --- /dev/null +++ b/modules/text/encoding.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class EncodingModule(Module): + name = "text.encoding" + version = "0.1.0" + description = "Text encoding tools" + + async def on_load(self) -> None: + return None diff --git a/modules/text/formatting.py b/modules/text/formatting.py new file mode 100644 index 0000000..b2d293b --- /dev/null +++ b/modules/text/formatting.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class FormattingModule(Module): + name = "text.formatting" + version = "0.1.0" + description = "Text formatting tools" + + async def on_load(self) -> None: + return None diff --git a/modules/text/generation.py b/modules/text/generation.py new file mode 100644 index 0000000..b748b64 --- /dev/null +++ b/modules/text/generation.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class GenerationModule(Module): + name = "text.generation" + version = "0.1.0" + description = "Text generation tools" + + async def on_load(self) -> None: + return None diff --git a/modules/utility/API.md b/modules/utility/API.md new file mode 100644 index 0000000..ba70246 --- /dev/null +++ b/modules/utility/API.md @@ -0,0 +1,3 @@ +# Utility API + +Public module hooks and extension points for utility. diff --git a/modules/utility/COMMANDS.md b/modules/utility/COMMANDS.md new file mode 100644 index 0000000..e9f201c --- /dev/null +++ b/modules/utility/COMMANDS.md @@ -0,0 +1,13 @@ +# Utility Commands + +This module provides the core utility commands described in the main README. + +## Commands +- `.calc` - Simple calculator +- `.help` - Command help +- `.note` - Note management +- `.remind` - Reminders +- `.convert` - Unit conversion +- `.weather` - Weather lookup +- `.crypto` - Crypto price lookup +- `.plugin` - Plugin management shortcuts (`list`, `install`, `config`, `configui`, `rollback`, etc.) diff --git a/modules/utility/CONFIG.md b/modules/utility/CONFIG.md new file mode 100644 index 0000000..89058a9 --- /dev/null +++ b/modules/utility/CONFIG.md @@ -0,0 +1,3 @@ +# Utility Configuration + +Configuration options are defined in config/modules.yml under utility. diff --git a/modules/utility/README.md b/modules/utility/README.md new file mode 100644 index 0000000..5fa62f3 --- /dev/null +++ b/modules/utility/README.md @@ -0,0 +1,3 @@ +# Utility Module + +Overview of the utility module. diff --git a/modules/utility/__init__.py b/modules/utility/__init__.py new file mode 100644 index 0000000..17ea8ef --- /dev/null +++ b/modules/utility/__init__.py @@ -0,0 +1,465 @@ +import asyncio + +from core.commands import ArgumentSpec +from core.module import Module + + +class UtilityModule(Module): + name = "utility" + version = "0.1.0" + description = "Utility commands" + + async def on_load(self) -> None: + builder = self.commands + await self.app.database.execute( + "CREATE TABLE IF NOT EXISTS utility_notes (name TEXT PRIMARY KEY, value TEXT)" + ) + + async def on_callback(evt): + event = evt.payload.get("event") + if not event or not hasattr(event, "data"): + return + data = event.data.decode("utf-8") + if not data.startswith("overub_cfg:"): + return + _, action, name = data.split(":", 2) + cfg = self.app.config.get_plugin_config(name) + if action == "toggle": + cfg["enabled"] = not cfg.get("enabled", True) + self.app.config.set_plugin_config(name, cfg) + self.app.config.save() + await event.answer("Toggled") + if action == "auto": + cfg["auto_update"] = not cfg.get("auto_update", True) + self.app.config.set_plugin_config(name, cfg) + self.app.config.save() + await event.answer("Auto-update toggled") + + self.app.events.on("on_callback_query", on_callback) + + @builder.command( + name="calc", + description="Simple calculator", + category="utility", + usage=".calc ", + example=".calc 2+2", + ) + async def calc_cmd(event, args): + expression = " ".join(args) + if not expression: + await event.reply("Usage: .calc ") + return + try: + result = eval(expression, {"__builtins__": {}}, {}) + except Exception: + await event.reply("Invalid expression") + return + await event.reply(str(result)) + + @builder.command( + name="help", + description="Show command help", + category="utility", + usage=".help [command]", + example=".help calc", + ) + async def help_cmd(event, args): + name = args[0] if args else None + await event.reply(self.app.commands.help_text(name)) + + @builder.command( + name="note", + description="Manage notes", + category="utility", + usage=".note [name] [value]", + ) + async def note_cmd(event, args): + if not args: + await event.reply("Usage: .note [name] [value]") + return + action = args[0] + if action == "list": + rows = await self.app.database.fetchall("SELECT name FROM utility_notes") + names = [row["name"] for row in rows] + await event.reply(", ".join(names) if names else "No notes") + return + if len(args) < 2: + await event.reply("Note name required") + return + name = args[1] + if action == "add": + value = " ".join(args[2:]) + await self.app.database.execute( + "INSERT OR REPLACE INTO utility_notes (name, value) VALUES (?, ?)", + (name, value), + ) + await event.reply("Saved") + return + if action == "get": + row = await self.app.database.fetchone( + "SELECT value FROM utility_notes WHERE name=?", + (name,), + ) + await event.reply(row["value"] if row else "Not found") + return + if action == "del": + await self.app.database.execute( + "DELETE FROM utility_notes WHERE name=?", + (name,), + ) + await event.reply("Deleted") + return + await event.reply("Unknown action") + + @builder.command( + name="remind", + description="Set a reminder in seconds", + category="utility", + usage=".remind ", + arguments=[ + ArgumentSpec("seconds", int, True), + ArgumentSpec("text", str, True, variadic=True), + ], + ) + async def remind_cmd(event, args): + if len(args) < 2: + await event.reply("Usage: .remind ") + return + try: + seconds = int(args[0]) + except ValueError: + await event.reply("Invalid seconds") + return + text = " ".join(args[1:]) + await event.reply(f"Reminder set for {seconds}s") + await asyncio.sleep(seconds) + await event.reply(f"Reminder: {text}") + + @builder.command( + name="convert", + description="Stub unit conversion", + category="utility", + usage=".convert ", + arguments=[ + ArgumentSpec("value", float, True), + ArgumentSpec("unit", str, True), + ], + ) + async def convert_cmd(event, args): + value = float(args[0]) if args else 0.0 + unit = args[1].lower() if len(args) > 1 else "" + conversions = { + "km-mi": value * 0.621371, + "mi-km": value / 0.621371, + "c-f": (value * 9 / 5) + 32, + "f-c": (value - 32) * 5 / 9, + } + if unit not in conversions: + await event.reply("Units: km-mi, mi-km, c-f, f-c") + return + await event.reply(str(round(conversions[unit], 4))) + + @builder.command( + name="weather", + description="Stub weather", + category="utility", + usage=".weather ", + arguments=[ArgumentSpec("city", str, True, variadic=True)], + ) + async def weather_cmd(event, args): + city = " ".join(args) + geo_url = f"https://geocoding-api.open-meteo.com/v1/search?name={city}&count=1" + geo = await self.app.http.get_json(geo_url) + if not geo or not geo.get("results"): + await event.reply("Location not found") + return + loc = geo["results"][0] + lat = loc["latitude"] + lon = loc["longitude"] + weather_url = f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}¤t_weather=true" + data = await self.app.http.get_json(weather_url) + if not data or "current_weather" not in data: + await event.reply("Weather unavailable") + return + current = data["current_weather"] + await event.reply( + f"{loc['name']}: {current['temperature']}°C, wind {current['windspeed']} km/h" + ) + + @builder.command( + name="crypto", + description="Stub crypto prices", + category="utility", + usage=".crypto ", + arguments=[ArgumentSpec("symbol", str, True)], + ) + async def crypto_cmd(event, args): + symbol = args[0].lower() if args else "" + map_ids = { + "btc": "bitcoin", + "eth": "ethereum", + "bnb": "binancecoin", + "sol": "solana", + } + coin_id = map_ids.get(symbol) + if not coin_id: + await event.reply("Symbols: btc, eth, bnb, sol") + return + url = f"https://api.coingecko.com/api/v3/simple/price?ids={coin_id}&vs_currencies=usd" + data = await self.app.http.get_json(url) + if not data or coin_id not in data: + await event.reply("Price unavailable") + return + price = data[coin_id]["usd"] + await event.reply(f"{symbol.upper()}: ${price}") + + @builder.command( + name="plugin", + description="Manage plugins", + category="utility", + usage=".plugin [name]", + example=".plugin list", + ) + async def plugin_cmd(event, args): + if not args: + await event.reply("Usage: .plugin [name]") + return + action = args[0] + name = args[1] if len(args) > 1 else None + if action == "list": + installed = self.app.plugins.list_installed() + loaded = set(self.app.plugins.list()) + if installed: + summary = [] + for plugin in installed: + state = "loaded" if plugin in loaded else "unloaded" + summary.append(f"{plugin} ({state})") + await event.reply("Installed plugins: " + ", ".join(summary)) + else: + await event.reply("No plugins installed") + return + if action == "search": + query = " ".join(args[1:]) + if not query: + await event.reply("Query required") + return + try: + results = await self.app.plugins.search(query) + except Exception as exc: + await event.reply(f"Search failed: {exc}") + return + await event.reply(results or "No results") + return + if action == "install": + repo = " ".join(args[1:]) + if not repo: + await event.reply("Repo required") + return + try: + installed = await self.app.plugins.install(repo) + except Exception as exc: + self.app.update_service.record_event( + action="plugin_install", + status="failed", + meta={"repo": repo}, + ) + await event.reply(f"Install failed: {exc}") + return + self.app.update_service.record_event( + action="plugin_install", + status="success", + meta={"name": installed}, + ) + await event.reply(f"Installed {installed}") + return + if action == "uninstall": + if not name: + await event.reply("Plugin name required") + return + await self.app.plugins.uninstall(name) + self.app.update_service.record_event( + action="plugin_uninstall", + status="success", + meta={"name": name}, + ) + await event.reply(f"Uninstalled {name}") + return + if action == "update": + target = name or "all" + if target == "check": + updates = [] + for item in self.app.plugins.list_installed(): + try: + await self.app.plugins.fetch(item) + updates.append(item) + except Exception: + continue + await event.reply("Checked updates for: " + ", ".join(updates) if updates else "No plugins checked") + return + if target == "exclude": + if len(args) < 3: + await event.reply("Plugin name required") + return + cfg = self.app.config.get_plugin_config(args[2]) + cfg["auto_update"] = False + self.app.config.set_plugin_config(args[2], cfg) + self.app.config.save() + await event.reply(f"Excluded {args[2]} from auto-updates") + return + if target == "all": + updated = [] + if not self.app.plugins.plugin_path.exists(): + await event.reply("No plugins directory") + return + for item in self.app.plugins.plugin_path.iterdir(): + if item.is_dir(): + try: + await self.app.plugins.update(item.name) + updated.append(item.name) + except Exception as exc: + self.app.update_service.record_event( + action="plugin_update", + status="failed", + meta={"name": item.name}, + ) + await event.reply(f"Update failed for {item.name}: {exc}") + return + for plugin_name in updated: + self.app.update_service.record_event( + action="plugin_update", + status="success", + meta={"name": plugin_name}, + ) + await event.reply("Updated: " + ", ".join(updated) if updated else "No plugins updated") + return + try: + await self.app.plugins.update(target) + except Exception as exc: + self.app.update_service.record_event( + action="plugin_update", + status="failed", + meta={"name": target}, + ) + await event.reply(f"Update failed: {exc}") + return + self.app.update_service.record_event( + action="plugin_update", + status="success", + meta={"name": target}, + ) + await event.reply(f"Updated {target}") + return + if action == "rollback": + if not name: + await event.reply("Plugin name required") + return + ref = args[2] if len(args) > 2 else "HEAD~1" + try: + await self.app.plugins.rollback(name, ref) + except Exception as exc: + self.app.update_service.record_event( + action="plugin_rollback", + status="failed", + meta={"name": name, "ref": ref}, + ) + await event.reply(f"Rollback failed: {exc}") + return + self.app.update_service.record_event( + action="plugin_rollback", + status="success", + meta={"name": name, "ref": ref}, + ) + await event.reply(f"Rolled back {name}") + return + if action == "info": + if not name: + await event.reply("Plugin name required") + return + info = await self.app.plugins.info(name) + await event.reply(str(info)) + return + if action == "config": + if not name: + await event.reply("Plugin name required") + return + if len(args) == 2: + from core.config_ui import render_plugin_config + + cfg = self.app.config.get_plugin_config(name) + await event.reply(render_plugin_config(name, cfg)) + return + if len(args) >= 4: + key = args[2] + value = " ".join(args[3:]) + cfg = self.app.config.get_plugin_config(name) + cfg.setdefault("settings", {})[key] = value + self.app.config.set_plugin_config(name, cfg) + self.app.config.save() + await event.reply("Config updated") + return + await event.reply("Usage: .plugin config [key value]") + return + if action == "configui": + if not name: + await event.reply("Plugin name required") + return + try: + from telethon import Button + except Exception: + await event.reply("Buttons unavailable") + return + cfg = self.app.config.get_plugin_config(name) + status = "enabled" if cfg.get("enabled", True) else "disabled" + auto = "auto" if cfg.get("auto_update", True) else "manual" + buttons = [ + [Button.inline(f"Toggle ({status})", data=f"overub_cfg:toggle:{name}")], + [Button.inline(f"Auto-update ({auto})", data=f"overub_cfg:auto:{name}")], + ] + await event.reply(f"Config for {name}", buttons=buttons) + return + if action == "remote": + if not name: + await event.reply("Plugin name required") + return + try: + remote = await self.app.plugins.remote(name) + except Exception as exc: + await event.reply(f"Remote failed: {exc}") + return + await event.reply(remote) + return + if action == "fetch": + if not name: + await event.reply("Plugin name required") + return + try: + output = await self.app.plugins.fetch(name) + except Exception as exc: + await event.reply(f"Fetch failed: {exc}") + return + await event.reply(output or "Fetched") + return + if not name: + await event.reply("Plugin name required") + return + if action == "reload": + await self.app.plugins.reload(name) + await event.reply(f"Reloaded {name}") + return + if action == "enable": + await self.app.plugins.enable(name) + cfg = self.app.config.get_plugin_config(name) + cfg["enabled"] = True + self.app.config.set_plugin_config(name, cfg) + self.app.config.save() + await event.reply(f"Enabled {name}") + return + if action == "disable": + await self.app.plugins.disable(name) + cfg = self.app.config.get_plugin_config(name) + cfg["enabled"] = False + self.app.config.set_plugin_config(name, cfg) + self.app.config.save() + await event.reply(f"Disabled {name}") + return + await event.reply("Unknown action") diff --git a/modules/utility/calculator.py b/modules/utility/calculator.py new file mode 100644 index 0000000..122078d --- /dev/null +++ b/modules/utility/calculator.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class CalculatorModule(Module): + name = "utility.calculator" + version = "0.1.0" + description = "Calculator tools" + + async def on_load(self) -> None: + return None diff --git a/modules/utility/converter.py b/modules/utility/converter.py new file mode 100644 index 0000000..bd71dde --- /dev/null +++ b/modules/utility/converter.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class ConverterModule(Module): + name = "utility.converter" + version = "0.1.0" + description = "Conversion tools" + + async def on_load(self) -> None: + return None diff --git a/modules/utility/crypto.py b/modules/utility/crypto.py new file mode 100644 index 0000000..3d3e087 --- /dev/null +++ b/modules/utility/crypto.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class CryptoModule(Module): + name = "utility.crypto" + version = "0.1.0" + description = "Crypto tools" + + async def on_load(self) -> None: + return None diff --git a/modules/utility/notes.py b/modules/utility/notes.py new file mode 100644 index 0000000..0c25517 --- /dev/null +++ b/modules/utility/notes.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class NotesModule(Module): + name = "utility.notes" + version = "0.1.0" + description = "Note management tools" + + async def on_load(self) -> None: + return None diff --git a/modules/utility/reminders.py b/modules/utility/reminders.py new file mode 100644 index 0000000..4822213 --- /dev/null +++ b/modules/utility/reminders.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class RemindersModule(Module): + name = "utility.reminders" + version = "0.1.0" + description = "Reminder tools" + + async def on_load(self) -> None: + return None diff --git a/modules/utility/weather.py b/modules/utility/weather.py new file mode 100644 index 0000000..8266911 --- /dev/null +++ b/modules/utility/weather.py @@ -0,0 +1,10 @@ +from core.module import Module + + +class WeatherModule(Module): + name = "utility.weather" + version = "0.1.0" + description = "Weather tools" + + async def on_load(self) -> None: + return None diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/plugins/external/.gitkeep b/plugins/external/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8f2d8b4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +telethon>=1.30.0 +PyYAML>=6.0 +aiosqlite>=0.19.0 +aiofiles>=23.2.1 +# Optional backends/tools: +# asyncpg>=0.29.0 +# motor>=3.3.0 +# redis>=5.0.0 +# psutil>=5.9.0 +# cryptography>=41.0.0 +# aiohttp>=3.9.0 +# Pillow>=10.0.0 diff --git a/scripts/create-release.sh b/scripts/create-release.sh new file mode 100755 index 0000000..94a5a59 --- /dev/null +++ b/scripts/create-release.sh @@ -0,0 +1,19 @@ +#!/bin/bash +VERSION=$1 +BRANCH=${2:-main} + +if [ -z "$VERSION" ]; then + echo "Usage: ./create-release.sh [branch]" + exit 1 +fi + +git tag -a "$VERSION" -m "Release $VERSION" +git push origin "$VERSION" + +tea releases create \ + --tag "$VERSION" \ + --target "$BRANCH" \ + --title "OverUB $VERSION" \ + --note "$(git log $(git describe --tags --abbrev=0 HEAD^)..HEAD --pretty=format:'- %s')" + +echo "Release $VERSION created successfully!" diff --git a/scripts/setup-branches.sh b/scripts/setup-branches.sh new file mode 100755 index 0000000..9236f0e --- /dev/null +++ b/scripts/setup-branches.sh @@ -0,0 +1,19 @@ +#!/bin/bash +REPO=$1 + +if [ -z "$REPO" ]; then + echo "Usage: ./setup-branches.sh " + exit 1 +fi + +cd "$REPO" || exit + +for branch in main beta dev lts; do + git checkout -b "$branch" 2>/dev/null || git checkout "$branch" + git push -u origin "$branch" +done + +tea repos protect-branch main --enable-push --require-signed-commits +tea repos protect-branch lts --enable-push --require-signed-commits + +echo "Branches configured for $REPO" diff --git a/scripts/setup-gitea.sh b/scripts/setup-gitea.sh new file mode 100755 index 0000000..84ff776 --- /dev/null +++ b/scripts/setup-gitea.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -e + +REPO_NAME=${1:-overub} +REPO_DESC="OverUB - Advanced Modular Telegram Userbot by @overspend1" + +if ! command -v tea &> /dev/null; then + echo "Tea CLI not found. Please install it first." + exit 1 +fi + +if ! tea login list &> /dev/null; then + echo "Not logged into Gitea. Run 'tea login add' first." + exit 1 +fi + +tea repos create \ + --name "$REPO_NAME" \ + --description "$REPO_DESC" \ + --private false \ + --init \ + --gitignore Python 2>/dev/null || echo "Repository might already exist" + +BRANCHES=("main" "beta" "dev" "lts") +for branch in "${BRANCHES[@]}"; do + git checkout -b "$branch" 2>/dev/null || git checkout "$branch" + git push -u origin "$branch" 2>/dev/null || echo "Branch $branch already exists" +done + +tea repos protect-branch main \ + --enable-push \ + --enable-push-whitelist \ + --require-signed-commits || echo "Main branch already protected" + +tea repos protect-branch lts \ + --enable-push \ + --enable-push-whitelist \ + --require-signed-commits || echo "LTS branch already protected" + +mkdir -p .gitea/ISSUE_TEMPLATE + +cat > .gitea/ISSUE_TEMPLATE/bug_report.md << 'TEMPLATE' +--- +name: Bug Report +about: Report a bug +labels: bug +--- + +## Bug Description + +## Steps to Reproduce +1. +2. +3. + +## Expected Behavior + +## Actual Behavior + +## Version Information +- OverUB Version: +- Python Version: +- OS: +TEMPLATE + +git add .gitea/ 2>/dev/null || true +git commit -m "Add issue templates" 2>/dev/null || true +git push 2>/dev/null || true + +echo "Gitea setup complete." diff --git a/scripts/update-bot.sh b/scripts/update-bot.sh new file mode 100755 index 0000000..0f8fda2 --- /dev/null +++ b/scripts/update-bot.sh @@ -0,0 +1,12 @@ +#!/bin/bash +BRANCH=${1:-main} + +cd /path/to/overub || exit + +git fetch origin +git checkout "$BRANCH" +git pull origin "$BRANCH" + +systemctl restart overub + +echo "Bot updated to latest $BRANCH" diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..21529e8 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,22 @@ +import unittest + +from core.commands import CommandRegistry + + +class CommandRegistryTests(unittest.TestCase): + def test_parse(self): + registry = CommandRegistry(prefix=".") + parsed = registry.parse(".ping 1 2") + self.assertEqual(parsed.name, "ping") + self.assertEqual(parsed.args, ["1", "2"]) + + def test_flags(self): + registry = CommandRegistry(prefix=".") + parsed = registry.parse(".cmd --foo=bar -ab") + self.assertEqual(parsed.flags["foo"], "bar") + self.assertTrue(parsed.flags["a"]) + self.assertTrue(parsed.flags["b"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..830519b --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,17 @@ +import unittest +from pathlib import Path + +from core.config import ConfigManager + + +class ConfigTests(unittest.TestCase): + def test_plugin_defaults(self): + cfg = ConfigManager(Path("config/config.yml"), Path("config/modules.yml")) + cfg.load() + plugin_cfg = cfg.get_plugin_config("example") + self.assertIn("enabled", plugin_cfg) + self.assertIn("settings", plugin_cfg) + + +if __name__ == "__main__": + unittest.main()