Initial commit
This commit is contained in:
176
.gitignore
vendored
176
.gitignore
vendored
@@ -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
|
||||
|
||||
29
README.md
29
README.md
@@ -1,3 +1,28 @@
|
||||
# overub
|
||||
# OverUB
|
||||
|
||||
OverUB - Advanced Modular Telegram Userbot by @overspend1
|
||||
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 <name>`
|
||||
- `python -m __main__ validate-plugin <path>`
|
||||
- `python -m __main__ build-plugin <path>`
|
||||
- `python -m __main__ docs-plugin <path>`
|
||||
- `python -m __main__ test-plugin <path>`
|
||||
|
||||
## 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`
|
||||
|
||||
19
__main__.py
Normal file
19
__main__.py
Normal file
@@ -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())
|
||||
131
config/config.yml
Normal file
131
config/config.yml
Normal file
@@ -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
|
||||
75
config/modules.yml
Normal file
75
config/modules.yml
Normal file
@@ -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
|
||||
0
core/__init__.py
Normal file
0
core/__init__.py
Normal file
331
core/app.py
Normal file
331
core/app.py
Normal file
@@ -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())
|
||||
8
core/audit.py
Normal file
8
core/audit.py
Normal file
@@ -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)
|
||||
55
core/backup.py
Normal file
55
core/backup.py
Normal file
@@ -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()
|
||||
78
core/backup_service.py
Normal file
78
core/backup_service.py
Normal file
@@ -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)
|
||||
46
core/bus.py
Normal file
46
core/bus.py
Normal file
@@ -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)
|
||||
54
core/cache.py
Normal file
54
core/cache.py
Normal file
@@ -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,
|
||||
}
|
||||
73
core/changelog.py
Normal file
73
core/changelog.py
Normal file
@@ -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"
|
||||
167
core/cli.py
Normal file
167
core/cli.py
Normal file
@@ -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)
|
||||
100
core/client.py
Normal file
100
core/client.py
Normal file
@@ -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
|
||||
312
core/commands.py
Normal file
312
core/commands.py
Normal file
@@ -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)
|
||||
213
core/config.py
Normal file
213
core/config.py
Normal file
@@ -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)
|
||||
17
core/config_ui.py
Normal file
17
core/config_ui.py
Normal file
@@ -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)
|
||||
308
core/database.py
Normal file
308
core/database.py
Normal file
@@ -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)
|
||||
18
core/error_handler.py
Normal file
18
core/error_handler.py
Normal file
@@ -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
|
||||
46
core/events.py
Normal file
46
core/events.py
Normal file
@@ -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
|
||||
33
core/gitea.py
Normal file
33
core/gitea.py
Normal file
@@ -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")
|
||||
66
core/http.py
Normal file
66
core/http.py
Normal file
@@ -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)
|
||||
476
core/loader.py
Normal file
476
core/loader.py
Normal file
@@ -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()
|
||||
106
core/logger.py
Normal file
106
core/logger.py
Normal file
@@ -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
|
||||
109
core/migrations.py
Normal file
109
core/migrations.py
Normal file
@@ -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
|
||||
30
core/module.py
Normal file
30
core/module.py
Normal file
@@ -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)
|
||||
38
core/module_updates.py
Normal file
38
core/module_updates.py
Normal file
@@ -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()
|
||||
21
core/monitor.py
Normal file
21
core/monitor.py
Normal file
@@ -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)
|
||||
46
core/notifications.py
Normal file
46
core/notifications.py
Normal file
@@ -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
|
||||
50
core/permissions.py
Normal file
50
core/permissions.py
Normal file
@@ -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
|
||||
143
core/plugin.py
Normal file
143
core/plugin.py
Normal file
@@ -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
|
||||
14
core/profiler.py
Normal file
14
core/profiler.py
Normal file
@@ -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))
|
||||
23
core/rate_limiter.py
Normal file
23
core/rate_limiter.py
Normal file
@@ -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
|
||||
29
core/sandbox.py
Normal file
29
core/sandbox.py
Normal file
@@ -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)
|
||||
42
core/scheduler.py
Normal file
42
core/scheduler.py
Normal file
@@ -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()
|
||||
80
core/testing.py
Normal file
80
core/testing.py
Normal file
@@ -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}'")
|
||||
238
core/update_service.py
Normal file
238
core/update_service.py
Normal file
@@ -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
|
||||
234
core/updater.py
Normal file
234
core/updater.py
Normal file
@@ -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
|
||||
43
core/version.py
Normal file
43
core/version.py
Normal file
@@ -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,
|
||||
)
|
||||
18
core/versioning.py
Normal file
18
core/versioning.py
Normal file
@@ -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
|
||||
18
core/webhook.py
Normal file
18
core/webhook.py
Normal file
@@ -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"),
|
||||
}
|
||||
46
core/webhook_server.py
Normal file
46
core/webhook_server.py
Normal file
@@ -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
|
||||
49
docs/API_REFERENCE.md
Normal file
49
docs/API_REFERENCE.md
Normal file
@@ -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.
|
||||
8
docs/BEST_PRACTICES.md
Normal file
8
docs/BEST_PRACTICES.md
Normal file
@@ -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.
|
||||
26
docs/EXAMPLES.md
Normal file
26
docs/EXAMPLES.md
Normal file
@@ -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"}
|
||||
```
|
||||
73
docs/GITEA_SETUP.md
Normal file
73
docs/GITEA_SETUP.md
Normal file
@@ -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 <login-name>
|
||||
```
|
||||
|
||||
## 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`
|
||||
49
docs/PLUGIN_GUIDE.md
Normal file
49
docs/PLUGIN_GUIDE.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# OverUB Plugin Guide
|
||||
|
||||
## Structure
|
||||
A plugin is a Python package placed in `plugins/external/<plugin_name>` 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 <name>`
|
||||
- `python -m __main__ validate-plugin <path>`
|
||||
- `python -m __main__ build-plugin <path>`
|
||||
- `python -m __main__ docs-plugin <path>`
|
||||
- `python -m __main__ test-plugin <path>`
|
||||
0
modules/__init__.py
Normal file
0
modules/__init__.py
Normal file
3
modules/admin/API.md
Normal file
3
modules/admin/API.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Admin API
|
||||
|
||||
Public module hooks and extension points for admin.
|
||||
15
modules/admin/COMMANDS.md
Normal file
15
modules/admin/COMMANDS.md
Normal file
@@ -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 <name>` - Update and reload module
|
||||
- `.modules update list` - List updates
|
||||
3
modules/admin/CONFIG.md
Normal file
3
modules/admin/CONFIG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Admin Configuration
|
||||
|
||||
Configuration options are defined in config/modules.yml under admin.
|
||||
3
modules/admin/README.md
Normal file
3
modules/admin/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Admin Module
|
||||
|
||||
Overview of the admin module.
|
||||
306
modules/admin/__init__.py
Normal file
306
modules/admin/__init__.py
Normal file
@@ -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 <on|off>",
|
||||
)
|
||||
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 <on|off> [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 <on|off> [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 <list|enable|disable|reload|info|config|update> [name]",
|
||||
example=".modules list",
|
||||
permission="admin",
|
||||
)
|
||||
async def modules_cmd(event, args):
|
||||
if not args:
|
||||
await event.reply("Usage: .modules <list|enable|disable|reload|info|config|update> [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 <check|all|rollback> [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 <create|list|delete> <core|modules|plugins> [name]",
|
||||
permission="admin",
|
||||
)
|
||||
async def backup_cmd(event, args):
|
||||
if not args:
|
||||
await event.reply("Usage: .backup <create|list|delete|auto|schedule> <core|modules|plugins> [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 <create|list|delete|auto|schedule> <core|modules|plugins> [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")
|
||||
10
modules/admin/analytics.py
Normal file
10
modules/admin/analytics.py
Normal file
@@ -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
|
||||
10
modules/admin/backup.py
Normal file
10
modules/admin/backup.py
Normal file
@@ -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
|
||||
10
modules/admin/moderation.py
Normal file
10
modules/admin/moderation.py
Normal file
@@ -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
|
||||
10
modules/admin/welcome.py
Normal file
10
modules/admin/welcome.py
Normal file
@@ -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
|
||||
3
modules/automation/API.md
Normal file
3
modules/automation/API.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Automation API
|
||||
|
||||
Public module hooks and extension points for automation.
|
||||
9
modules/automation/COMMANDS.md
Normal file
9
modules/automation/COMMANDS.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# Automation Commands
|
||||
|
||||
This module provides the core automation commands described in the main README.
|
||||
|
||||
## Commands
|
||||
- `.schedule`
|
||||
- `.afk`
|
||||
- `.autoreply`
|
||||
- `.autoforward`
|
||||
3
modules/automation/CONFIG.md
Normal file
3
modules/automation/CONFIG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Automation Configuration
|
||||
|
||||
Configuration options are defined in config/modules.yml under automation.
|
||||
3
modules/automation/README.md
Normal file
3
modules/automation/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Automation Module
|
||||
|
||||
Overview of the automation module.
|
||||
118
modules/automation/__init__.py
Normal file
118
modules/automation/__init__.py
Normal file
@@ -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 <reason>",
|
||||
)
|
||||
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 <seconds> <text>",
|
||||
)
|
||||
async def schedule_cmd(event, args):
|
||||
if len(args) < 2:
|
||||
await event.reply("Usage: .schedule <seconds> <text>")
|
||||
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 <on|off> [text]",
|
||||
)
|
||||
async def autoreply_cmd(event, args):
|
||||
if not args:
|
||||
await event.reply("Usage: .autoreply <on|off> [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 <on|off> [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 <on|off> <chat_id>")
|
||||
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 <on|off> <chat_id>")
|
||||
10
modules/automation/afk.py
Normal file
10
modules/automation/afk.py
Normal file
@@ -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
|
||||
10
modules/automation/auto_reply.py
Normal file
10
modules/automation/auto_reply.py
Normal file
@@ -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
|
||||
10
modules/automation/forwarding.py
Normal file
10
modules/automation/forwarding.py
Normal file
@@ -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
|
||||
10
modules/automation/scheduler.py
Normal file
10
modules/automation/scheduler.py
Normal file
@@ -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
|
||||
3
modules/developer/API.md
Normal file
3
modules/developer/API.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Developer API
|
||||
|
||||
Public module hooks and extension points for developer.
|
||||
17
modules/developer/COMMANDS.md
Normal file
17
modules/developer/COMMANDS.md
Normal file
@@ -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
|
||||
3
modules/developer/CONFIG.md
Normal file
3
modules/developer/CONFIG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Developer Configuration
|
||||
|
||||
Configuration options are defined in config/modules.yml under developer.
|
||||
3
modules/developer/README.md
Normal file
3
modules/developer/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Developer Module
|
||||
|
||||
Overview of the developer module.
|
||||
331
modules/developer/__init__.py
Normal file
331
modules/developer/__init__.py
Normal file
@@ -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 <expr>",
|
||||
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 <code>",
|
||||
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 <json>",
|
||||
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 <pattern> <text>",
|
||||
permission="admin",
|
||||
)
|
||||
async def regex_cmd(event, args):
|
||||
import re
|
||||
|
||||
if len(args) < 2:
|
||||
await event.reply("Usage: .regex <pattern> <text>")
|
||||
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 <check|now|info|remote|fetch|release|rollback|channel|changelog|history|stats|emergency|force|critical>",
|
||||
permission="admin",
|
||||
)
|
||||
async def update_cmd(event, args):
|
||||
if not args:
|
||||
await event.reply("Usage: .update <check|now|info|remote|fetch|release|rollback|channel|changelog|history|stats|emergency|force|critical>")
|
||||
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 <owner/repo> <tag>")
|
||||
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 <keyword>")
|
||||
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 <tag>")
|
||||
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 <tag1> <tag2>")
|
||||
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 <list|apply|rollback|validate> [name|steps]",
|
||||
permission="admin",
|
||||
)
|
||||
async def migrate_cmd(event, args):
|
||||
if not args:
|
||||
await event.reply("Usage: .migrate <list|apply|rollback|validate> [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")
|
||||
10
modules/developer/api.py
Normal file
10
modules/developer/api.py
Normal file
@@ -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
|
||||
10
modules/developer/code.py
Normal file
10
modules/developer/code.py
Normal file
@@ -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
|
||||
10
modules/developer/debug.py
Normal file
10
modules/developer/debug.py
Normal file
@@ -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
|
||||
3
modules/fun/API.md
Normal file
3
modules/fun/API.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Fun API
|
||||
|
||||
Public module hooks and extension points for fun.
|
||||
10
modules/fun/COMMANDS.md
Normal file
10
modules/fun/COMMANDS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Fun Commands
|
||||
|
||||
This module provides the core fun commands described in the main README.
|
||||
|
||||
## Commands
|
||||
- `.dice`
|
||||
- `.joke`
|
||||
- `.8ball`
|
||||
- `.rps`
|
||||
- `.trivia`
|
||||
3
modules/fun/CONFIG.md
Normal file
3
modules/fun/CONFIG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Fun Configuration
|
||||
|
||||
Configuration options are defined in config/modules.yml under fun.
|
||||
3
modules/fun/README.md
Normal file
3
modules/fun/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Fun Module
|
||||
|
||||
Overview of the fun module.
|
||||
81
modules/fun/__init__.py
Normal file
81
modules/fun/__init__.py
Normal file
@@ -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 <question>",
|
||||
)
|
||||
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 <rock|paper|scissors>",
|
||||
)
|
||||
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}")
|
||||
10
modules/fun/games.py
Normal file
10
modules/fun/games.py
Normal file
@@ -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
|
||||
10
modules/fun/jokes.py
Normal file
10
modules/fun/jokes.py
Normal file
@@ -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
|
||||
10
modules/fun/random.py
Normal file
10
modules/fun/random.py
Normal file
@@ -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
|
||||
3
modules/media/API.md
Normal file
3
modules/media/API.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Media API
|
||||
|
||||
Public module hooks and extension points for media.
|
||||
11
modules/media/COMMANDS.md
Normal file
11
modules/media/COMMANDS.md
Normal file
@@ -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`
|
||||
3
modules/media/CONFIG.md
Normal file
3
modules/media/CONFIG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Media Configuration
|
||||
|
||||
Configuration options are defined in config/modules.yml under media.
|
||||
3
modules/media/README.md
Normal file
3
modules/media/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Media Module
|
||||
|
||||
Overview of the media module.
|
||||
141
modules/media/__init__.py
Normal file
141
modules/media/__init__.py
Normal file
@@ -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 <url>",
|
||||
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 <format>",
|
||||
)
|
||||
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 <width> <height>",
|
||||
)
|
||||
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 <width> <height>")
|
||||
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)
|
||||
10
modules/media/compress.py
Normal file
10
modules/media/compress.py
Normal file
@@ -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
|
||||
10
modules/media/convert.py
Normal file
10
modules/media/convert.py
Normal file
@@ -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
|
||||
10
modules/media/download.py
Normal file
10
modules/media/download.py
Normal file
@@ -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
|
||||
10
modules/media/edit.py
Normal file
10
modules/media/edit.py
Normal file
@@ -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
|
||||
10
modules/media/stickers.py
Normal file
10
modules/media/stickers.py
Normal file
@@ -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
|
||||
3
modules/search/API.md
Normal file
3
modules/search/API.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Search API
|
||||
|
||||
Public module hooks and extension points for search.
|
||||
10
modules/search/COMMANDS.md
Normal file
10
modules/search/COMMANDS.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Search Commands
|
||||
|
||||
This module provides the core search commands described in the main README.
|
||||
|
||||
## Commands
|
||||
- `.google`
|
||||
- `.wiki`
|
||||
- `.yt`
|
||||
- `.translate`
|
||||
- `.define`
|
||||
3
modules/search/CONFIG.md
Normal file
3
modules/search/CONFIG.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Search Configuration
|
||||
|
||||
Configuration options are defined in config/modules.yml under search.
|
||||
3
modules/search/README.md
Normal file
3
modules/search/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Search Module
|
||||
|
||||
Overview of the search module.
|
||||
111
modules/search/__init__.py
Normal file
111
modules/search/__init__.py
Normal file
@@ -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 <query>",
|
||||
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 <query>",
|
||||
)
|
||||
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 <query>",
|
||||
)
|
||||
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 <text>",
|
||||
)
|
||||
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 <word>",
|
||||
)
|
||||
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)
|
||||
10
modules/search/dictionary.py
Normal file
10
modules/search/dictionary.py
Normal file
@@ -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
|
||||
10
modules/search/media.py
Normal file
10
modules/search/media.py
Normal file
@@ -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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user