Initial commit

This commit is contained in:
overspend1
2025-12-21 17:03:20 +01:00
committed by overspend1
parent 5680f023af
commit 5926a38e47
132 changed files with 6552 additions and 170 deletions

176
.gitignore vendored
View File

@@ -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

View File

@@ -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
View 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
View 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
View 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
View File

331
core/app.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

3
modules/admin/API.md Normal file
View File

@@ -0,0 +1,3 @@
# Admin API
Public module hooks and extension points for admin.

15
modules/admin/COMMANDS.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
# Admin Configuration
Configuration options are defined in config/modules.yml under admin.

3
modules/admin/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Admin Module
Overview of the admin module.

306
modules/admin/__init__.py Normal file
View 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")

View 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
View 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

View 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
View 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

View File

@@ -0,0 +1,3 @@
# Automation API
Public module hooks and extension points for automation.

View File

@@ -0,0 +1,9 @@
# Automation Commands
This module provides the core automation commands described in the main README.
## Commands
- `.schedule`
- `.afk`
- `.autoreply`
- `.autoforward`

View File

@@ -0,0 +1,3 @@
# Automation Configuration
Configuration options are defined in config/modules.yml under automation.

View File

@@ -0,0 +1,3 @@
# Automation Module
Overview of the automation module.

View 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
View 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

View 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

View 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

View 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
View File

@@ -0,0 +1,3 @@
# Developer API
Public module hooks and extension points for developer.

View 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

View File

@@ -0,0 +1,3 @@
# Developer Configuration
Configuration options are defined in config/modules.yml under developer.

View File

@@ -0,0 +1,3 @@
# Developer Module
Overview of the developer module.

View 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
View 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
View 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

View 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
View File

@@ -0,0 +1,3 @@
# Fun API
Public module hooks and extension points for fun.

10
modules/fun/COMMANDS.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
# Fun Configuration
Configuration options are defined in config/modules.yml under fun.

3
modules/fun/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Fun Module
Overview of the fun module.

81
modules/fun/__init__.py Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
# Media API
Public module hooks and extension points for media.

11
modules/media/COMMANDS.md Normal file
View 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
View File

@@ -0,0 +1,3 @@
# Media Configuration
Configuration options are defined in config/modules.yml under media.

3
modules/media/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Media Module
Overview of the media module.

141
modules/media/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
# Search API
Public module hooks and extension points for search.

View 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
View File

@@ -0,0 +1,3 @@
# Search Configuration
Configuration options are defined in config/modules.yml under search.

3
modules/search/README.md Normal file
View File

@@ -0,0 +1,3 @@
# Search Module
Overview of the search module.

111
modules/search/__init__.py Normal file
View 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)

View 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
View 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