mirror of
https://gitflic.ru/project/alt-gnome/karkas.git
synced 2026-10-14 01:57:06 +03:00
Merged with feat/config-module
This commit is contained in:
@@ -1,2 +0,0 @@
|
||||
import ocab_core
|
||||
import service
|
||||
@@ -0,0 +1,23 @@
|
||||
FROM python:3.12-slim as builder
|
||||
|
||||
RUN pip install poetry
|
||||
RUN mkdir -p /app
|
||||
COPY . /app
|
||||
|
||||
# Фикс
|
||||
|
||||
RUN sed -i '/ocab-core = {/{s/, develop = true//}' /app/src/gnomik/pyproject.toml && \
|
||||
sed -i '/ocab-modules = {/{s/, develop = true//}' /app/src/gnomik/pyproject.toml && \
|
||||
sed -i '/ocab-core = {/{s/, develop = true//}' /app/src/ocab_modules/pyproject.toml
|
||||
|
||||
WORKDIR /app/src/gnomik
|
||||
|
||||
RUN poetry lock && poetry install
|
||||
|
||||
FROM python:3.12-slim as base
|
||||
|
||||
COPY --from=builder /app/src/gnomik /app
|
||||
|
||||
WORKDIR /app
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
CMD ["python", "-m", "gnomik"]
|
||||
@@ -0,0 +1,14 @@
|
||||
**/Dockerfile
|
||||
**/*.dockerignore
|
||||
**/docker-compose.yml
|
||||
|
||||
**/.git
|
||||
**/.gitignore
|
||||
|
||||
**/.venv
|
||||
|
||||
**/.mypy_cache
|
||||
**/__pycache__/
|
||||
|
||||
src/gnomik/config.yaml
|
||||
src/gnomik/database/*
|
||||
@@ -0,0 +1,55 @@
|
||||
# Gnomик
|
||||
|
||||

|
||||
|
||||
Чат-бот помощник в [ALT Gnome Chat](https://t.me/alt_gnome_chat).
|
||||
|
||||
|
||||
ALT Regular Gnome Community - открытое сообщество пользователей операционной системы ALT Regular Gnome.
|
||||
|
||||
- [Канал](https://t.me/alt_gnome)
|
||||
- [Wiki](https://alt-gnome.wiki)
|
||||
|
||||
## Описание
|
||||
|
||||
Gnomик - это чат-бот, разработанный на платформе Open Chat AI Bot (OCAB) для Telegram. Он предоставляет различные функции и возможности, помогающие пользователям операционной системы ALT Regular Gnome.
|
||||
|
||||
## Функционал
|
||||
|
||||
<!--
|
||||
TODO: описать функционал
|
||||
-->
|
||||
|
||||
## Запуск
|
||||
|
||||
### Docker
|
||||
|
||||
1. Соберите Docker-образ:
|
||||
```bash
|
||||
docker build -t gnomik .
|
||||
```
|
||||
2. Запустите контейнер:
|
||||
```bash
|
||||
docker run -p 9000:9000 -v ./config.yaml:/app/config.yaml -v ./database:/app/database gnomik
|
||||
```
|
||||
|
||||
Замените `./config.yaml` и `./database` на пути к вашим локальным файлам конфигурации и паки для базы данных.
|
||||
|
||||
### Вручную
|
||||
|
||||
1. Активируйте виртуальное окружение Gnomика:
|
||||
```bash
|
||||
poetry shell
|
||||
```
|
||||
2. Запустите бота:
|
||||
```bash
|
||||
python -m gnomik
|
||||
```
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Конфигурация бота находится в файле `config.yaml`.
|
||||
|
||||
## Модули
|
||||
|
||||
Список загружаемых модулей указан в файле `__main__.py`.
|
||||
@@ -0,0 +1,18 @@
|
||||
core:
|
||||
mode: WEBHOOK
|
||||
token: xxx
|
||||
webhook:
|
||||
public_url: xxx
|
||||
filters:
|
||||
approved_chat_id: -4128011756 | -4128011756
|
||||
default_chat_tag: '@alt_gnome_chat'
|
||||
miniapp:
|
||||
public_url: xxx
|
||||
yandexgpt:
|
||||
catalogid: xxx
|
||||
inword: помогите | не работает
|
||||
prompt: Ты чат-бот ...
|
||||
startword: Бот| Бот, | бот | бот,
|
||||
token: xxx
|
||||
token_for_answer: 2000
|
||||
token_for_request: 8000
|
||||
@@ -0,0 +1,12 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: src/gnomik/Dockerfile
|
||||
ports:
|
||||
- 9000:9000
|
||||
volumes:
|
||||
- ./config.yaml:/app/config.yaml
|
||||
- ./database:/app/database
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
@@ -0,0 +1,29 @@
|
||||
import asyncio
|
||||
|
||||
from ocab_core import OCAB
|
||||
from ocab_modules import module_loader
|
||||
|
||||
|
||||
async def main():
|
||||
ocab = OCAB()
|
||||
await ocab.init_app(
|
||||
[
|
||||
module_loader("standard", "config", safe=False),
|
||||
module_loader("standard", "database", safe=False),
|
||||
module_loader("standard", "fsm_database_storage", safe=False),
|
||||
module_loader("standard", "roles", safe=False),
|
||||
module_loader("external", "yandexgpt", safe=False),
|
||||
#
|
||||
module_loader("standard", "command_helper"),
|
||||
module_loader("standard", "info"),
|
||||
module_loader("standard", "filters"),
|
||||
module_loader("external", "create_report_apps"),
|
||||
module_loader("standard", "admin"),
|
||||
module_loader("standard", "message_processing"),
|
||||
module_loader("standard", "miniapp", safe=False),
|
||||
]
|
||||
)
|
||||
await ocab.start()
|
||||
|
||||
|
||||
asyncio.run(main())
|
||||
Generated
+2162
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
@@ -0,0 +1,15 @@
|
||||
[tool.poetry]
|
||||
name = "gnomik"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Максим Слипенко <maxim@slipenko.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "~3.12"
|
||||
ocab-core = { extras=["webhook"], path = "../ocab_core", develop = true }
|
||||
ocab-modules = { path = "../ocab_modules", develop = true }
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -0,0 +1,23 @@
|
||||
# OCAB Core
|
||||
|
||||
Это ядро OCAB, содержащее базовые компоненты:
|
||||
|
||||
- Система управления модулями.
|
||||
- Логирование.
|
||||
- Утилиты.
|
||||
|
||||
## Система управления модулями
|
||||
|
||||
Система управления модулями отвечает за:
|
||||
|
||||
- Загрузку модулей.
|
||||
- Проверку зависимостей.
|
||||
- Предоставление API для взаимодействия между модулями.
|
||||
|
||||
## Логирование
|
||||
|
||||
Модуль логирования предоставляет функции для записи логов в консоль.
|
||||
|
||||
## Утилиты
|
||||
|
||||
Модуль утилит содержит вспомогательные функции, например, для форматирования текста.
|
||||
@@ -1,21 +0,0 @@
|
||||
TELEGRAM:
|
||||
TOKEN: xxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
APPROVED_CHAT_ID: "-123456789 | -012345678"
|
||||
ADMINCHATID: -12345678
|
||||
DEFAULT_CHAT_TAG: "@alt_gnome_chat"
|
||||
CHECK_BOT: True
|
||||
|
||||
YANDEXGPT:
|
||||
TOKEN: xxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
TOKEN_FOR_REQUEST: 8000
|
||||
TOKEN_FOR_ANSWER: 2000
|
||||
CATALOGID: xxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
PROMPT: "Ты чат-бот ..."
|
||||
STARTWORD: "Бот| Бот, | бот | бот,"
|
||||
INWORD: "помогите | не работает"
|
||||
|
||||
ROLES:
|
||||
ADMIN: 2
|
||||
MODERATOR: 1
|
||||
USER: 0
|
||||
BOT: 3
|
||||
@@ -1,29 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import traceback
|
||||
|
||||
|
||||
def setup_logger():
|
||||
"""
|
||||
Настройка логирования
|
||||
"""
|
||||
current_date = time.strftime("%d-%m-%Y")
|
||||
log_dir = os.path.join(os.path.dirname(__file__), "log")
|
||||
os.makedirs(log_dir, exist_ok=True)
|
||||
log_file = os.path.join(log_dir, f"log-{current_date}.log")
|
||||
|
||||
logging.basicConfig(
|
||||
filename=log_file,
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
def log(message):
|
||||
if isinstance(message, Exception):
|
||||
error_message = f"Error: {str(message)}\n{traceback.format_exc()}"
|
||||
logging.error(error_message)
|
||||
else:
|
||||
logging.info(message)
|
||||
@@ -1,61 +0,0 @@
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
|
||||
from ocab_core.logger import log, setup_logger
|
||||
from ocab_core.modules_system import ModulesManager
|
||||
from ocab_core.modules_system.loaders import FSLoader
|
||||
from ocab_core.modules_system.loaders.unsafe_fs_loader import UnsafeFSLoader
|
||||
from ocab_core.singleton import Singleton
|
||||
from ocab_modules.standard.config.config import get_telegram_token
|
||||
from service import paths
|
||||
|
||||
bot_modules = [
|
||||
UnsafeFSLoader(f"{paths.modules_standard}/config"),
|
||||
UnsafeFSLoader(f"{paths.modules_standard}/database"),
|
||||
UnsafeFSLoader(f"{paths.modules_standard}/fsm_database_storage"),
|
||||
UnsafeFSLoader(f"{paths.modules_standard}/roles"),
|
||||
UnsafeFSLoader(f"{paths.modules_external}/yandexgpt"),
|
||||
FSLoader(f"{paths.modules_standard}/command_helper"),
|
||||
FSLoader(f"{paths.modules_standard}/info"),
|
||||
FSLoader(f"{paths.modules_standard}/filters"),
|
||||
FSLoader(f"{paths.modules_external}/create_report_apps"),
|
||||
FSLoader(f"{paths.modules_standard}/admin"),
|
||||
FSLoader(f"{paths.modules_standard}/message_processing"),
|
||||
]
|
||||
|
||||
|
||||
async def main():
|
||||
bot = None
|
||||
setup_logger()
|
||||
|
||||
app = Singleton()
|
||||
|
||||
try:
|
||||
app.bot = Bot(token=get_telegram_token())
|
||||
app.modules_manager = ModulesManager()
|
||||
|
||||
for module_loader in bot_modules:
|
||||
info = module_loader.info()
|
||||
log(f"Loading {info.name}({info.id}) module")
|
||||
await app.modules_manager.load(module_loader)
|
||||
|
||||
app.dp = Dispatcher(storage=app.storage["_fsm_storage"])
|
||||
|
||||
app.dp.include_routers(*app.storage["_routers"])
|
||||
|
||||
for middleware in app.storage["_outer_message_middlewares"]:
|
||||
app.dp.message.outer_middleware.register(middleware)
|
||||
|
||||
await app.modules_manager.late_init()
|
||||
await app.dp.start_polling(app.bot)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if bot is not None:
|
||||
await app.bot.session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -1 +0,0 @@
|
||||
from .fs_loader import FSLoader
|
||||
@@ -1,24 +0,0 @@
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dataclasses_json import dataclass_json
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class ModuleInfo:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
author: str | list[str]
|
||||
privileged: bool
|
||||
dependencies: dict
|
||||
|
||||
|
||||
class AbstractLoader:
|
||||
def info(self) -> ModuleInfo:
|
||||
raise NotImplementedError
|
||||
|
||||
def load(self) -> types.ModuleType:
|
||||
raise NotImplementedError
|
||||
@@ -1,85 +0,0 @@
|
||||
import semver
|
||||
|
||||
from ocab_core.modules_system.loaders.base import AbstractLoader
|
||||
|
||||
|
||||
def is_version_compatible(version, requirement):
|
||||
def parse_requirement(req):
|
||||
if req.startswith("^"):
|
||||
base_version = req[1:]
|
||||
base_version_info = semver.VersionInfo.parse(base_version)
|
||||
range_start = base_version_info
|
||||
range_end = base_version_info.bump_major()
|
||||
return [f">={range_start}", f"<{range_end}"]
|
||||
else:
|
||||
return [req]
|
||||
|
||||
for r in parse_requirement(requirement):
|
||||
if not semver.Version.parse(version).match(r):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ModulesManager:
|
||||
def __init__(self):
|
||||
self.modules = []
|
||||
|
||||
async def load(self, loader: AbstractLoader):
|
||||
info = loader.info()
|
||||
|
||||
# Check if the module is already loaded
|
||||
if any(mod["info"].id == info.id for mod in self.modules):
|
||||
return
|
||||
|
||||
# Check dependencies
|
||||
for dependency, version in info.dependencies.items():
|
||||
loaded_dependency = next(
|
||||
(mod for mod in self.modules if mod["info"].id == dependency), None
|
||||
)
|
||||
if not loaded_dependency:
|
||||
raise Exception(
|
||||
f"Module {info.id} depends on {dependency}, but it is not loaded"
|
||||
)
|
||||
loaded_dependency_info = loaded_dependency["info"]
|
||||
if not is_version_compatible(loaded_dependency_info.version, version):
|
||||
raise Exception(
|
||||
f"Module {info.id} depends on {dependency}, "
|
||||
f"but version {version} is not compatible"
|
||||
)
|
||||
|
||||
module = loader.load()
|
||||
|
||||
self.modules.append(
|
||||
{
|
||||
"info": info,
|
||||
"module": module,
|
||||
}
|
||||
)
|
||||
|
||||
if hasattr(module, "module_init"):
|
||||
await module.module_init()
|
||||
|
||||
async def late_init(self):
|
||||
for m in self.modules:
|
||||
module = m["module"]
|
||||
if hasattr(module, "module_late_init"):
|
||||
await module.module_late_init()
|
||||
|
||||
def get_by_id(self, module_id: str):
|
||||
module = next(
|
||||
(mod for mod in self.modules if mod["info"].id == module_id), None
|
||||
)
|
||||
if not module:
|
||||
raise Exception(f"Module with id {module_id} not loaded")
|
||||
|
||||
return module["module"]
|
||||
|
||||
def get_info_by_id(self, module_id: str):
|
||||
module = next(
|
||||
(mod for mod in self.modules if mod["info"].id == module_id), None
|
||||
)
|
||||
if not module:
|
||||
raise Exception(f"Module with id {module_id} not loaded")
|
||||
|
||||
return module["info"]
|
||||
@@ -0,0 +1 @@
|
||||
from .main import OCAB
|
||||
@@ -0,0 +1,38 @@
|
||||
import importlib
|
||||
import os
|
||||
import traceback
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
from aiogram.types import Update
|
||||
|
||||
|
||||
def get_module_directory(module_name):
|
||||
spec = importlib.util.find_spec(module_name)
|
||||
if spec is None:
|
||||
raise ImportError(f"Module {module_name} not found")
|
||||
module_path = spec.origin
|
||||
if module_path is None:
|
||||
raise ImportError(f"Module {module_name} has no origin path")
|
||||
return os.path.dirname(module_path)
|
||||
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
async def register_bot_webhook(app: FastAPI, bot: Bot, dp: Dispatcher):
|
||||
async def handle_webhook(request: Request):
|
||||
try:
|
||||
update = Update.model_validate(
|
||||
await request.json(), context={"bot": bot}
|
||||
)
|
||||
await dp.feed_update(bot, update)
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
return {"ok": False}
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
app.post("/webhook")(handle_webhook)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,46 @@
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
app_logger = logging.getLogger("ocab")
|
||||
log_level = logging.INFO
|
||||
|
||||
|
||||
def patch_logger(logger_: logging.Logger):
|
||||
logger_.handlers = []
|
||||
formatter = logging.Formatter("%(asctime)s %(message)s", datefmt="%H:%M:%S")
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger_.addHandler(console_handler)
|
||||
logger_.propagate = False
|
||||
logger_.setLevel(log_level)
|
||||
return logger_
|
||||
|
||||
|
||||
def setup_logger():
|
||||
"""
|
||||
Настройка логирования
|
||||
"""
|
||||
patch_logger(app_logger)
|
||||
|
||||
|
||||
def log(message):
|
||||
if isinstance(message, Exception):
|
||||
error_message = f"Error: {str(message)}\n{traceback.format_exc()}"
|
||||
app_logger.error(error_message)
|
||||
else:
|
||||
app_logger.info(message)
|
||||
|
||||
|
||||
try:
|
||||
from hypercorn.logging import Logger as HypercornLogger
|
||||
|
||||
class CustomLogger(HypercornLogger):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.error_logger:
|
||||
patch_logger(self.error_logger)
|
||||
if self.access_logger:
|
||||
patch_logger(self.access_logger)
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
@@ -0,0 +1,118 @@
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot, Dispatcher
|
||||
|
||||
from ocab_core.lib import register_bot_webhook
|
||||
from ocab_core.logger import CustomLogger, log, setup_logger
|
||||
from ocab_core.modules_system import ModulesManager
|
||||
from ocab_core.modules_system.public_api import get_module
|
||||
from ocab_core.singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.config import IConfig
|
||||
|
||||
|
||||
class OCAB:
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
|
||||
async def init_app(self, bot_modules):
|
||||
setup_logger()
|
||||
singleton = Singleton()
|
||||
|
||||
try:
|
||||
singleton.modules_manager = ModulesManager()
|
||||
|
||||
for module_loader in bot_modules:
|
||||
info = module_loader.info()
|
||||
log(f"Loading {info.name} ({info.id}) module")
|
||||
await singleton.modules_manager.load(module_loader)
|
||||
|
||||
register_config()
|
||||
|
||||
config: "IConfig" = get_module("standard.config", "config")
|
||||
|
||||
config.load()
|
||||
|
||||
singleton.bot = Bot(token=config.get("core::token"))
|
||||
singleton.dp = Dispatcher(storage=singleton.storage["_fsm_storage"])
|
||||
singleton.dp.include_routers(*singleton.storage["_routers"])
|
||||
|
||||
for middleware in singleton.storage["_outer_message_middlewares"]:
|
||||
singleton.dp.message.outer_middleware.register(middleware)
|
||||
|
||||
await singleton.modules_manager.late_init()
|
||||
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
raise
|
||||
|
||||
async def start(self):
|
||||
config: "IConfig" = get_module("standard.config", "config")
|
||||
|
||||
if config.get("core::mode") == "WEBHOOK":
|
||||
await self.start_webhook_mode()
|
||||
else:
|
||||
await self.start_long_polling_mode()
|
||||
|
||||
return
|
||||
|
||||
async def start_long_polling_mode(self):
|
||||
singleton = Singleton()
|
||||
await singleton.bot.delete_webhook()
|
||||
await singleton.dp.start_polling(singleton.bot)
|
||||
|
||||
async def start_webhook_mode(self):
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config as HyperConfig
|
||||
except ImportError:
|
||||
log(
|
||||
"Error: FastAPI and Hypercorn are required"
|
||||
"for webhook mode. Please install them."
|
||||
)
|
||||
return
|
||||
|
||||
singleton = Singleton()
|
||||
app = FastAPI()
|
||||
config = get_module("standard.config", "config")
|
||||
app.mount(config.get("miniapp::prefix"), singleton.storage["webapp"])
|
||||
await register_bot_webhook(app, singleton.bot, singleton.dp)
|
||||
await singleton.bot.set_webhook(config.get("core::webhook::public_url"))
|
||||
hyperConfig = HyperConfig()
|
||||
hyperConfig.bind = [f"0.0.0.0:{config.get("core::webhook::port")}"]
|
||||
hyperConfig.logger_class = CustomLogger
|
||||
await serve(app, hyperConfig)
|
||||
|
||||
|
||||
def register_config():
|
||||
config: "IConfig" = get_module("standard.config", "config")
|
||||
|
||||
config.register(
|
||||
"core::token",
|
||||
"password",
|
||||
visible=False,
|
||||
)
|
||||
|
||||
config.register(
|
||||
"core::mode",
|
||||
"select",
|
||||
options=["WEBHOOK", "LONG_POLLING"],
|
||||
default_value="WEBHOOK",
|
||||
visible=False,
|
||||
)
|
||||
|
||||
config.register(
|
||||
"core::webhook::port",
|
||||
"int",
|
||||
default_value=9000,
|
||||
visible=False,
|
||||
)
|
||||
|
||||
config.register(
|
||||
"core::webhook::public_url",
|
||||
"string",
|
||||
visible=False,
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
from .fs_loader import FSLoader
|
||||
from .unsafe_fs_loader import UnsafeFSLoader
|
||||
@@ -0,0 +1,43 @@
|
||||
import types
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from dataclasses_json import dataclass_json
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class DependencyInfo:
|
||||
version: str
|
||||
uses: Optional[List[str]] = None
|
||||
|
||||
|
||||
DependencyType = Union[str, DependencyInfo]
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class Dependencies:
|
||||
required: Optional[Dict[str, DependencyType]] = None
|
||||
optional: Optional[Dict[str, DependencyType]] = None
|
||||
|
||||
|
||||
@dataclass_json
|
||||
@dataclass
|
||||
class ModuleInfo:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
version: str
|
||||
author: Union[str, List[str]]
|
||||
privileged: bool
|
||||
dependencies: Dependencies
|
||||
pythonDependencies: Optional[Dependencies] = None
|
||||
|
||||
|
||||
class AbstractLoader:
|
||||
def info(self) -> ModuleInfo:
|
||||
raise NotImplementedError
|
||||
|
||||
def load(self) -> types.ModuleType:
|
||||
raise NotImplementedError
|
||||
+28
-10
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from RestrictedPython import compile_restricted_exec
|
||||
|
||||
# from ocab_core.logger import log
|
||||
from ocab_core.modules_system.loaders.unsafe_fs_loader import UnsafeFSLoader
|
||||
from ocab_core.modules_system.safe.policy import (
|
||||
ALLOWED_IMPORTS,
|
||||
@@ -16,13 +17,30 @@ class FSLoader(UnsafeFSLoader):
|
||||
super().__init__(path)
|
||||
self.builtins = BUILTINS.copy()
|
||||
self.builtins["__import__"] = self._hook_import
|
||||
self.module_info = self.info()
|
||||
self.allowed_python_dependencies = self._get_allowed_python_dependencies()
|
||||
|
||||
def load(self):
|
||||
info = self.info()
|
||||
if info.privileged:
|
||||
if self.module_info.privileged:
|
||||
raise Exception("Only non privileged modules are allowed to be imported")
|
||||
self.module_id = self.module_info.id
|
||||
|
||||
return self._hook_import(".")
|
||||
|
||||
def _get_allowed_python_dependencies(self):
|
||||
allowed = {}
|
||||
|
||||
if self.module_info.pythonDependencies:
|
||||
if self.module_info.pythonDependencies.required:
|
||||
allowed.update(self.module_info.pythonDependencies.required)
|
||||
if self.module_info.pythonDependencies.optional:
|
||||
allowed.update(self.module_info.pythonDependencies.optional)
|
||||
|
||||
for allowed_module in ALLOWED_IMPORTS:
|
||||
allowed[allowed_module] = "*"
|
||||
|
||||
return allowed
|
||||
|
||||
def _resolve_module_from_path(self, module_name: str):
|
||||
path = Path(self.path)
|
||||
|
||||
@@ -44,12 +62,14 @@ class FSLoader(UnsafeFSLoader):
|
||||
return file_path
|
||||
|
||||
def _hook_import(self, name: str, *args, **kwargs):
|
||||
for allowed in ALLOWED_IMPORTS:
|
||||
if name == allowed or name.startswith(f"{allowed}."):
|
||||
return __import__(name, *args, **kwargs)
|
||||
|
||||
if name == "ocab_core.modules_system.public_api":
|
||||
return __import__(name, *args, **kwargs)
|
||||
module = __import__(name, *args, **kwargs)
|
||||
module.__ocab_module_id__ = self.module_id
|
||||
return module
|
||||
|
||||
for key in self.allowed_python_dependencies.keys():
|
||||
if name == key or name.startswith(f"{key}."):
|
||||
return __import__(name, *args, **kwargs)
|
||||
|
||||
module_file_path = self._resolve_module_from_path(name)
|
||||
|
||||
@@ -58,9 +78,7 @@ class FSLoader(UnsafeFSLoader):
|
||||
|
||||
module = types.ModuleType(name)
|
||||
module.__dict__.update(
|
||||
{
|
||||
"__builtins__": self.builtins,
|
||||
}
|
||||
{"__builtins__": self.builtins, "__ocab_module_id__": self.module_id}
|
||||
)
|
||||
result = compile_restricted_exec(src, "<string>", policy=RestrictedPythonPolicy)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import importlib
|
||||
import inspect
|
||||
|
||||
import pkg_resources
|
||||
import semver
|
||||
|
||||
from ocab_core.modules_system.loaders.base import (
|
||||
AbstractLoader,
|
||||
DependencyInfo,
|
||||
ModuleInfo,
|
||||
)
|
||||
|
||||
|
||||
def is_version_compatible(version, requirement):
|
||||
def parse_requirement(req):
|
||||
if req.startswith("^"):
|
||||
base_version = req[1:]
|
||||
base_version_info = semver.VersionInfo.parse(base_version)
|
||||
range_start = base_version_info
|
||||
range_end = base_version_info.bump_major()
|
||||
return [f">={range_start}", f"<{range_end}"]
|
||||
else:
|
||||
return [req]
|
||||
|
||||
for r in parse_requirement(requirement):
|
||||
if r == "*":
|
||||
continue
|
||||
|
||||
if not semver.Version.parse(version).match(r):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def check_python_dependencies(info: ModuleInfo):
|
||||
if info.pythonDependencies and info.pythonDependencies.required:
|
||||
for dependency, req in info.pythonDependencies.required.items():
|
||||
try:
|
||||
importlib.import_module(dependency)
|
||||
except ImportError:
|
||||
raise Exception(
|
||||
f"Module {info.id} requires {dependency}, "
|
||||
f"but it is not installed"
|
||||
)
|
||||
|
||||
try:
|
||||
installed_version = pkg_resources.get_distribution(dependency).version
|
||||
except pkg_resources.DistributionNotFound:
|
||||
installed_version = "*"
|
||||
|
||||
if isinstance(req, str):
|
||||
required_version = req
|
||||
elif isinstance(req, DependencyInfo):
|
||||
required_version = req.version
|
||||
else:
|
||||
raise ValueError(f"Invalid dependency specification for {dependency}")
|
||||
|
||||
if not is_version_compatible(installed_version, required_version):
|
||||
raise Exception(
|
||||
f"Module {info.id} depends on {dependency} {required_version}, "
|
||||
f"but version {installed_version} is installed"
|
||||
)
|
||||
|
||||
|
||||
def check_dependency_uses(
|
||||
loaded_dependency, required_uses, dependent_module_id, dependency_id
|
||||
):
|
||||
module = loaded_dependency.get("module")
|
||||
if not module:
|
||||
raise Exception(f"Module object not found for dependency {dependency_id}")
|
||||
|
||||
for required_attr in required_uses:
|
||||
if not hasattr(module, required_attr):
|
||||
raise Exception(
|
||||
f"Module {dependent_module_id} requires '{required_attr}' "
|
||||
f"from {dependency_id}, but it is not available"
|
||||
)
|
||||
|
||||
|
||||
async def await_if_async(module, method_name):
|
||||
if hasattr(module, method_name):
|
||||
method = getattr(module, method_name)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
await method()
|
||||
else:
|
||||
method()
|
||||
|
||||
|
||||
class ModulesManager:
|
||||
def __init__(self):
|
||||
self.modules = []
|
||||
|
||||
async def load(self, loader: AbstractLoader):
|
||||
info = loader.info()
|
||||
|
||||
# Check if the module is already loaded
|
||||
if any(mod["info"].id == info.id for mod in self.modules):
|
||||
return
|
||||
|
||||
self.check_module_dependencies(info)
|
||||
check_python_dependencies(info)
|
||||
|
||||
module_info = {
|
||||
"info": info,
|
||||
"module": None,
|
||||
}
|
||||
self.modules.append(module_info)
|
||||
module = loader.load()
|
||||
module_info["module"] = module
|
||||
|
||||
await await_if_async(module, "module_init")
|
||||
|
||||
def check_module_dependencies(self, info: ModuleInfo):
|
||||
if info.dependencies.required:
|
||||
for dependency, req in info.dependencies.required.items():
|
||||
loaded_dependency = next(
|
||||
(mod for mod in self.modules if mod["info"].id == dependency), None
|
||||
)
|
||||
if not loaded_dependency:
|
||||
raise Exception(
|
||||
f"Module {info.id} depends on {dependency},"
|
||||
f"but it is not loaded"
|
||||
)
|
||||
|
||||
loaded_dependency_info = loaded_dependency["info"]
|
||||
|
||||
if isinstance(req, str):
|
||||
required_version = req
|
||||
elif isinstance(req, DependencyInfo):
|
||||
required_version = req.version
|
||||
if req.uses:
|
||||
check_dependency_uses(
|
||||
loaded_dependency, req.uses, info.id, dependency
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid dependency specification for {dependency}"
|
||||
)
|
||||
|
||||
if not is_version_compatible(
|
||||
loaded_dependency_info.version, required_version
|
||||
):
|
||||
raise Exception(
|
||||
f"Module {info.id} depends on {dependency} {required_version}, "
|
||||
f"but version {loaded_dependency_info.version} is loaded"
|
||||
)
|
||||
|
||||
async def late_init(self):
|
||||
for m in self.modules:
|
||||
module = m["module"]
|
||||
await await_if_async(module, "module_late_init")
|
||||
|
||||
def get_by_id(self, module_id: str):
|
||||
module = next(
|
||||
(mod for mod in self.modules if mod["info"].id == module_id), None
|
||||
)
|
||||
if not module:
|
||||
raise Exception(f"Module with id {module_id} not loaded")
|
||||
|
||||
return module["module"]
|
||||
|
||||
def get_info_by_id(self, module_id: str):
|
||||
module = next(
|
||||
(mod for mod in self.modules if mod["info"].id == module_id), None
|
||||
)
|
||||
if not module:
|
||||
raise Exception(f"Module with id {module_id} not loaded")
|
||||
|
||||
return module["info"]
|
||||
+3
-1
@@ -1,10 +1,12 @@
|
||||
from ocab_core.logger import log
|
||||
|
||||
from .public_api import (
|
||||
Storage,
|
||||
get_fsm_context,
|
||||
get_module,
|
||||
log,
|
||||
register_outer_message_middleware,
|
||||
register_router,
|
||||
set_chat_menu_button,
|
||||
set_my_commands,
|
||||
)
|
||||
from .utils import Utils
|
||||
+47
-3
@@ -1,3 +1,4 @@
|
||||
import inspect
|
||||
import types
|
||||
from typing import Any, Tuple, Union
|
||||
|
||||
@@ -5,10 +6,16 @@ from aiogram import BaseMiddleware, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.fsm.storage.base import StorageKey
|
||||
|
||||
from ocab_core.logger import log
|
||||
# from ocab_core.logger import log
|
||||
from ocab_core.modules_system.loaders.base import DependencyInfo
|
||||
from ocab_core.singleton import Singleton
|
||||
|
||||
|
||||
async def set_chat_menu_button(menu_button):
|
||||
app = Singleton()
|
||||
await app.bot.set_chat_menu_button(menu_button=menu_button)
|
||||
|
||||
|
||||
def register_router(router: Router):
|
||||
app = Singleton()
|
||||
app.storage["_routers"].append(router)
|
||||
@@ -40,17 +47,47 @@ async def get_fsm_context(chat_id: int, user_id: int) -> FSMContext:
|
||||
|
||||
def set_fsm(storage):
|
||||
app = Singleton()
|
||||
log(storage)
|
||||
app.storage["_fsm_storage"] = storage
|
||||
|
||||
|
||||
def get_module(
|
||||
module_id: str, paths=None
|
||||
) -> Union[types.ModuleType, Union[Any, None], Tuple[Union[Any, None], ...]]:
|
||||
|
||||
caller_globals = inspect.currentframe().f_back.f_globals
|
||||
app = Singleton()
|
||||
|
||||
allowed_uses = None
|
||||
|
||||
if "__ocab_module_id__" in caller_globals:
|
||||
caller_module_id = caller_globals["__ocab_module_id__"]
|
||||
caller_module_info = app.modules_manager.get_info_by_id(caller_module_id)
|
||||
|
||||
if caller_module_info and caller_module_info.dependencies:
|
||||
dependency = None
|
||||
if caller_module_info.dependencies.required:
|
||||
dependency = caller_module_info.dependencies.required.get(module_id)
|
||||
if not dependency and caller_module_info.dependencies.optional:
|
||||
dependency = caller_module_info.dependencies.optional.get(module_id)
|
||||
|
||||
if (
|
||||
dependency
|
||||
and isinstance(dependency, DependencyInfo)
|
||||
and dependency.uses
|
||||
):
|
||||
allowed_uses = set(dependency.uses)
|
||||
|
||||
module = app.modules_manager.get_by_id(module_id)
|
||||
|
||||
if not module:
|
||||
raise ModuleNotFoundError(f"Module {module_id} not found")
|
||||
|
||||
if paths is None:
|
||||
if allowed_uses is not None:
|
||||
raise PermissionError(
|
||||
f"Direct access to module {module_id} is "
|
||||
f"not allowed for {caller_module_id}. Specify allowed attributes."
|
||||
)
|
||||
return module
|
||||
|
||||
if isinstance(paths, str):
|
||||
@@ -63,9 +100,16 @@ def get_module(
|
||||
try:
|
||||
parts = path.split(".")
|
||||
for part in parts:
|
||||
if allowed_uses is not None and part not in allowed_uses:
|
||||
raise AttributeError(
|
||||
f"Access to '{part}' is not allowed "
|
||||
+ f"for module {caller_module_id}"
|
||||
)
|
||||
current_obj = getattr(current_obj, part)
|
||||
results.append(current_obj)
|
||||
except AttributeError:
|
||||
except AttributeError as e:
|
||||
if "is not allowed" in str(e):
|
||||
raise PermissionError(str(e))
|
||||
results.append(None)
|
||||
|
||||
if len(results) == 1:
|
||||
+38
-3
@@ -1,3 +1,4 @@
|
||||
import types
|
||||
from _ast import AnnAssign
|
||||
from typing import Any
|
||||
|
||||
@@ -9,8 +10,8 @@ from RestrictedPython import (
|
||||
utility_builtins,
|
||||
)
|
||||
from RestrictedPython.Eval import default_guarded_getitem, default_guarded_getiter
|
||||
from RestrictedPython.Guards import (
|
||||
full_write_guard,
|
||||
from RestrictedPython.Guards import ( # guarded_setattr,; full_write_guard,
|
||||
_write_wrapper,
|
||||
guarded_unpack_sequence,
|
||||
safer_getattr,
|
||||
)
|
||||
@@ -95,6 +96,39 @@ def safes_getattr(object, name, default=None, getattr=safer_getattr):
|
||||
return getattr(object, name, default)
|
||||
|
||||
|
||||
trusted_settters_classes = []
|
||||
|
||||
|
||||
def safes_setattr(self, key, value):
|
||||
if (
|
||||
isinstance(getattr(type(self), key, None), property)
|
||||
and getattr(type(self), key).fset is not None
|
||||
):
|
||||
getattr(type(self), key).fset(self, value)
|
||||
return
|
||||
|
||||
|
||||
def write_guard():
|
||||
# ed scope abuse!
|
||||
# safetypes and Wrapper variables are used by guard()
|
||||
safetypes = {dict, list}
|
||||
Wrapper = _write_wrapper()
|
||||
|
||||
def guard(ob):
|
||||
# Don't bother wrapping simple types, or objects that claim to
|
||||
# handle their own write security.
|
||||
if type(ob) in safetypes or hasattr(ob, "_guarded_writes"):
|
||||
return ob
|
||||
|
||||
if type(ob) in trusted_settters_classes:
|
||||
setattr(ob, "__guarded_setattr__", types.MethodType(safes_setattr, ob))
|
||||
|
||||
# Hand the object to the Wrapper instance, then return the instance.
|
||||
return Wrapper(ob)
|
||||
|
||||
return guard
|
||||
|
||||
|
||||
BUILTINS = safe_builtins.copy()
|
||||
BUILTINS.update(utility_builtins)
|
||||
BUILTINS.update(limited_builtins)
|
||||
@@ -103,7 +137,8 @@ BUILTINS["__metaclass__"] = _metaclass
|
||||
BUILTINS["_getitem_"] = default_guarded_getitem
|
||||
BUILTINS["_getattr_"] = safes_getattr
|
||||
BUILTINS["_getiter_"] = default_guarded_getiter
|
||||
BUILTINS["_write_"] = full_write_guard
|
||||
BUILTINS["_write_"] = write_guard()
|
||||
BUILTINS["_unpack_sequence_"] = guarded_unpack_sequence
|
||||
BUILTINS["staticmethod"] = staticmethod
|
||||
BUILTINS["tuple"] = tuple
|
||||
BUILTINS["reversed"] = reversed
|
||||
Generated
+2140
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
[virtualenvs]
|
||||
in-project = true
|
||||
@@ -0,0 +1,26 @@
|
||||
[tool.poetry]
|
||||
name = "ocab-core"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Максим Слипенко <maxim@slipenko.com>"]
|
||||
readme = "README.md"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "~3.12"
|
||||
aiogram = "^3.10.0"
|
||||
setuptools = "^71.0.1"
|
||||
restrictedpython = "^7.1"
|
||||
semver = "^3.0.2"
|
||||
dataclasses-json = "^0.6.7"
|
||||
fastapi = { version = "^0.111.1", optional = true }
|
||||
hypercorn = { version = "^0.17.3", optional = true }
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ocab-modules = { path = "../ocab_modules", develop = true }
|
||||
|
||||
[tool.poetry.extras]
|
||||
webhook = ["fastapi", "hypercorn"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -0,0 +1,12 @@
|
||||
# OCAB Modules
|
||||
|
||||
OCAB Modules содержит набор модулей для платформы Open Chat AI Bot (OCAB).
|
||||
|
||||
## Описание
|
||||
|
||||
OCAB - это платформа для создания чат-ботов Telegram. Модули - это расширения, которые добавляют функциональность ботам OCAB.
|
||||
|
||||
## Типы модулей
|
||||
|
||||
* **Стандартные модули (standard.*):** Предоставляют основные функции, такие как управление пользователями, ролями и настройками.
|
||||
* **Дополнительные официальные модули (external.*):** Разработаны командой OCAB и предоставляют расширенные возможности, такие как интеграция с нейросетями, внешними сервисами и API.
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"id": "external.yandexgpt",
|
||||
"name": "Yandex GPT",
|
||||
"description": "Модуль для работы с Yandex GPT",
|
||||
"author": "OCAB Team",
|
||||
"version": "1.0.0",
|
||||
"privileged": true,
|
||||
"dependencies": {}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from .lib import module_loader
|
||||
@@ -0,0 +1,19 @@
|
||||
# Модуль Create Report Apps
|
||||
|
||||
Модуль `create_report_apps` предназначен для помощи пользователям в создании отчетов об ошибках в приложениях.
|
||||
|
||||
## Функциональность
|
||||
|
||||
- Задает пользователю ряд вопросов, необходимых для составления отчета.
|
||||
- Собирает информацию о системе пользователя.
|
||||
- Формирует отчет в текстовом формате.
|
||||
|
||||
## Команды
|
||||
|
||||
- `/create_report_apps` - запустить процесс создания отчета.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Отправьте команду `/create_report_apps` боту в личных сообщениях или в групповом чате.
|
||||
2. Ответьте на вопросы бота.
|
||||
3. Бот сформирует отчет и отправит его вам.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Модуль YandexGPT
|
||||
|
||||
Модуль `yandexgpt` интегрирует в бота OCAB нейросеть YandexGPT.
|
||||
|
||||
## Функциональность
|
||||
|
||||
- Позволяет боту отвечать на сообщения пользователей, используя YandexGPT.
|
||||
- Строит линию контекста для нейросети, используя историю сообщений.
|
||||
|
||||
## Конфигурация
|
||||
|
||||
- `yandexgpt::token` - API-ключ для доступа к YandexGPT.
|
||||
- `yandexgpt::catalogid` - идентификатор каталога YandexGPT.
|
||||
- `yandexgpt::prompt` - системная подсказка для YandexGPT.
|
||||
- `yandexgpt::startword` - слова, с которых должно начинаться сообщение, чтобы бот ответил.
|
||||
- `yandexgpt::inword` - слова, которые должны быть в сообщении, чтобы бот ответил.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Настройте конфигурационные параметры модуля.
|
||||
2. Отправьте боту сообщение, которое соответствует условиям, указанным в параметрах `startword` и `inword`.
|
||||
3. Бот ответит на сообщение, используя YandexGPT.
|
||||
+1
@@ -1 +1,2 @@
|
||||
from .handlers import answer_to_message
|
||||
from .main import module_init
|
||||
+28
-6
@@ -1,14 +1,36 @@
|
||||
# flake8: noqa
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import Message
|
||||
|
||||
from ocab_modules.external.yandexgpt.yandexgpt import *
|
||||
from ocab_modules.standard.config.config import (
|
||||
get_yandexgpt_catalog_id,
|
||||
get_yandexgpt_prompt,
|
||||
get_yandexgpt_token,
|
||||
from ocab_core.modules_system.public_api import get_module, log
|
||||
|
||||
from .yandexgpt import YandexGPT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.config import IConfig
|
||||
from ocab_modules.standard.database.db_api import add_message as IAddMessage
|
||||
|
||||
config: "IConfig" = get_module(
|
||||
"standard.config",
|
||||
"config",
|
||||
)
|
||||
from ocab_modules.standard.database.db_api import add_message
|
||||
|
||||
|
||||
def get_yandexgpt_catalog_id():
|
||||
return config.get("yandexgpt::catalogid")
|
||||
|
||||
|
||||
def get_yandexgpt_token():
|
||||
return config.get("yandexgpt::token")
|
||||
|
||||
|
||||
def get_yandexgpt_prompt():
|
||||
return config.get("yandexgpt::prompt")
|
||||
|
||||
|
||||
add_message: "IAddMessage" = get_module("standard.database", "db_api.add_message")
|
||||
|
||||
|
||||
async def answer_to_message(message: Message, bot: Bot):
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "external.yandexgpt",
|
||||
"name": "Yandex GPT",
|
||||
"description": "Модуль для работы с Yandex GPT",
|
||||
"author": "OCAB Team",
|
||||
"version": "1.0.0",
|
||||
"privileged": false,
|
||||
"dependencies": {
|
||||
"required": {
|
||||
"standard.config": "^1.0.0",
|
||||
"standard.database": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"pythonDependencies": {
|
||||
"required": {
|
||||
"aiohttp": "*",
|
||||
"requests": "*",
|
||||
"json": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ocab_core.modules_system.public_api import get_module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.config import IConfig
|
||||
|
||||
config: "IConfig" = get_module("standard.config", "config")
|
||||
|
||||
|
||||
def module_init():
|
||||
config.register(
|
||||
"yandexgpt::token",
|
||||
"password",
|
||||
required=True,
|
||||
)
|
||||
config.register(
|
||||
"yandexgpt::token_for_request",
|
||||
"int",
|
||||
default_value=8000,
|
||||
)
|
||||
config.register(
|
||||
"yandexgpt::token_for_answer",
|
||||
"int",
|
||||
default_value=2000,
|
||||
)
|
||||
|
||||
config.register(
|
||||
"yandexgpt::catalogid",
|
||||
"password",
|
||||
required=True,
|
||||
)
|
||||
|
||||
config.register(
|
||||
"yandexgpt::prompt",
|
||||
"string",
|
||||
default_value="Ты чат-бот ...",
|
||||
)
|
||||
|
||||
config.register(
|
||||
"yandexgpt::startword",
|
||||
"string",
|
||||
default_value="Бот| Бот, | бот | бот,",
|
||||
)
|
||||
|
||||
config.register(
|
||||
"yandexgpt::inword",
|
||||
"string",
|
||||
default_value="помогите | не работает",
|
||||
)
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# flake8: noqa
|
||||
from aiogram import F, Router
|
||||
|
||||
from src.ocab_modules.external.yandexgpt.handlers import answer_to_message
|
||||
from .handlers import answer_to_message
|
||||
|
||||
router = Router()
|
||||
# Если сообщение содержит в начале текст "Гномик" или "гномик" или отвечает на сообщение бота, то вызывается функция answer_to_message
|
||||
+25
-5
@@ -1,14 +1,31 @@
|
||||
# flake8: noqa
|
||||
import asyncio
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
|
||||
from ocab_core.logger import log
|
||||
from ocab_core.modules_system.public_api import get_module, log
|
||||
|
||||
from ...standard.config.config import *
|
||||
from ...standard.database import *
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.config import IConfig
|
||||
from ocab_modules.standard.database import db_api as IDbApi
|
||||
|
||||
db_api: "IDbApi" = get_module("standard.database", "db_api")
|
||||
|
||||
config: "IConfig" = get_module("standard.config", "config")
|
||||
|
||||
|
||||
def get_yandexgpt_token_for_answer():
|
||||
return config.get("yandexgpt::token_for_answer")
|
||||
|
||||
|
||||
def get_yandexgpt_token_for_request():
|
||||
return config.get("yandexgpt::token_for_request")
|
||||
|
||||
|
||||
def get_yandexgpt_prompt():
|
||||
return config.get("yandexgpt::prompt")
|
||||
|
||||
|
||||
class YandexGPT:
|
||||
@@ -108,8 +125,11 @@ class YandexGPT:
|
||||
input_messages,
|
||||
stream=False,
|
||||
temperature=0.6,
|
||||
max_tokens=get_yandexgpt_token_for_request(),
|
||||
max_tokens=None,
|
||||
):
|
||||
if max_tokens is None:
|
||||
max_tokens = get_yandexgpt_token_for_request()
|
||||
|
||||
url = "https://llm.api.cloud.yandex.net/foundationModels/v1/completion"
|
||||
gpt = f"gpt://{self.catalog_id}/yandexgpt/latest"
|
||||
headers = {
|
||||
@@ -0,0 +1,25 @@
|
||||
import importlib
|
||||
import os
|
||||
|
||||
from ocab_core.modules_system.loaders.fs_loader import FSLoader
|
||||
from ocab_core.modules_system.loaders.unsafe_fs_loader import UnsafeFSLoader
|
||||
|
||||
|
||||
def get_module_directory(module_name):
|
||||
spec = importlib.util.find_spec(module_name)
|
||||
if spec is None:
|
||||
raise ImportError(f"Module {module_name} not found")
|
||||
module_path = spec.origin
|
||||
if module_path is None:
|
||||
raise ImportError(f"Module {module_name} has no origin path")
|
||||
return os.path.dirname(module_path)
|
||||
|
||||
|
||||
ocab_modules_path = get_module_directory("ocab_modules")
|
||||
|
||||
|
||||
def module_loader(namespace: str, module_name: str, safe=True):
|
||||
if not safe:
|
||||
return UnsafeFSLoader(f"{ocab_modules_path}/{namespace}/{module_name}")
|
||||
else:
|
||||
return FSLoader(f"{ocab_modules_path}/{namespace}/{module_name}")
|
||||
@@ -0,0 +1,20 @@
|
||||
# Модуль Admin
|
||||
|
||||
Модуль `admin` предоставляет администраторам и модераторам чата инструменты для управления:
|
||||
|
||||
## Функциональность
|
||||
|
||||
- Удаление сообщений.
|
||||
- Получение ID чата.
|
||||
|
||||
## Команды
|
||||
|
||||
- `/rm` - удалить сообщение, на которое отвечает команда.
|
||||
- `/chatID` - получить ID текущего чата.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Ответьте на сообщение, которое нужно удалить.
|
||||
2. Отправьте команду `/rm`.
|
||||
|
||||
Чтобы получить ID чата, отправьте команду `/chatID`.
|
||||
+10
-1
@@ -1,10 +1,19 @@
|
||||
# flake8: noqa
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aiogram import Bot
|
||||
from aiogram.types import Message
|
||||
|
||||
from ocab_core.modules_system.public_api import get_module
|
||||
|
||||
get_default_chat_tag = get_module("standard.config", "get_default_chat_tag")
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.config import IConfig
|
||||
|
||||
config: "IConfig" = get_module("standard.config", "config")
|
||||
|
||||
|
||||
def get_default_chat_tag():
|
||||
return config.get("filters::default_chat_tag")
|
||||
|
||||
|
||||
async def delete_message(message: Message, bot: Bot):
|
||||
+3
-1
@@ -6,6 +6,8 @@
|
||||
"version": "1.0.0",
|
||||
"privileged": false,
|
||||
"dependencies": {
|
||||
"standard.filters": "^1.0.0"
|
||||
"required": {
|
||||
"standard.filters": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Модуль Command Helper
|
||||
|
||||
Модуль `command_helper` упрощает регистрацию команд бота и управление ими.
|
||||
|
||||
## Функциональность
|
||||
|
||||
- Регистрация команд бота.
|
||||
- Установка команд для пользователей в зависимости от их роли.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Импортируйте функцию `register_command`.
|
||||
2. Вызовите функцию `register_command`, передав ей название команды, ее описание и роль пользователя,
|
||||
которому доступна эта команда.
|
||||
|
||||
## Пример
|
||||
|
||||
```python
|
||||
from ocab_core.modules_system.public_api import get_module
|
||||
|
||||
register_command = get_module("standard.command_helper", "register_command")
|
||||
|
||||
register_command("my_command", "Описание моей команды", role="ADMIN")
|
||||
```
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"id": "standard.command_helper",
|
||||
"name": "Command helper",
|
||||
"description": "Модуль для отображения команд при вводе '/'",
|
||||
"author": "OCAB Team",
|
||||
"version": "1.0.0",
|
||||
"privileged": false,
|
||||
"dependencies": {
|
||||
"required": {
|
||||
"standard.roles": "^1.0.0",
|
||||
"standard.database": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Awaitable, Callable, Dict
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Dict
|
||||
|
||||
from aiogram import BaseMiddleware
|
||||
from aiogram.types import BotCommand, TelegramObject
|
||||
@@ -9,15 +9,18 @@ from ocab_core.modules_system.public_api import (
|
||||
set_my_commands,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.database import db_api as IDbApi
|
||||
from ocab_modules.standard.roles import Roles as IRoles
|
||||
|
||||
commands = dict()
|
||||
|
||||
|
||||
db_api = get_module(
|
||||
db_api: "IDbApi" = get_module(
|
||||
"standard.database",
|
||||
"db_api",
|
||||
)
|
||||
|
||||
Roles = get_module("standard.roles", "Roles")
|
||||
Roles: "IRoles" = get_module("standard.roles", "Roles")
|
||||
|
||||
|
||||
def register_command(command, description, role="USER"):
|
||||
@@ -0,0 +1,28 @@
|
||||
# Модуль Config
|
||||
|
||||
Модуль `config` управляет конфигурацией бота.
|
||||
|
||||
## Функциональность
|
||||
|
||||
- Загрузка конфигурации из файла `config.yaml`.
|
||||
- Сохранение конфигурации в файл.
|
||||
- Регистрация параметров конфигурации.
|
||||
- Получение значений параметров.
|
||||
|
||||
## Использование
|
||||
|
||||
1. Импортируйте объект `config`.
|
||||
2. Вызовите метод `register`, чтобы зарегистрировать параметр конфигурации.
|
||||
3. Вызовите метод `get`, чтобы получить значение параметра.
|
||||
|
||||
## Пример
|
||||
|
||||
```python
|
||||
from ocab_core.modules_system.public_api import get_module
|
||||
|
||||
config = get_module("standard.config", "config")
|
||||
|
||||
config.register("my_parameter", "string", default_value="default")
|
||||
|
||||
value = config.get("my_parameter")
|
||||
```
|
||||
@@ -0,0 +1,2 @@
|
||||
from .config import IConfig, config
|
||||
from .main import module_late_init
|
||||
@@ -0,0 +1,7 @@
|
||||
# flake8: noqa
|
||||
|
||||
from .config_manager import ConfigManager
|
||||
|
||||
IConfig = ConfigManager
|
||||
|
||||
config: ConfigManager = ConfigManager(config_path="config.yaml")
|
||||
@@ -0,0 +1,113 @@
|
||||
import inspect
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
class ConfigManager:
|
||||
def __init__(self, config_path: str):
|
||||
self.config_path = config_path
|
||||
|
||||
self._config: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
self._metadata: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def load(self, file_path: str = ""):
|
||||
if not file_path:
|
||||
file_path = self.config_path
|
||||
|
||||
def build_key(prev, next):
|
||||
if prev:
|
||||
return f"{prev}::{next}"
|
||||
return next
|
||||
|
||||
def recurse_set(value, key=""):
|
||||
if isinstance(value, dict):
|
||||
for k, v in value.items():
|
||||
recurse_set(v, build_key(key, k))
|
||||
return
|
||||
if key in self._metadata:
|
||||
self._config[key] = value
|
||||
|
||||
with open(file_path, "r", encoding="utf-8") as file:
|
||||
data = yaml.safe_load(file)
|
||||
recurse_set(data)
|
||||
|
||||
def save(self, file_path: str = ""):
|
||||
if not file_path:
|
||||
file_path = self.config_path
|
||||
|
||||
def nested_dict(flat_dict):
|
||||
result = {}
|
||||
for key, value in flat_dict.items():
|
||||
keys = key.split("::")
|
||||
d = result
|
||||
for k in keys[:-1]:
|
||||
d = d.setdefault(k, {})
|
||||
d[keys[-1]] = value
|
||||
return result
|
||||
|
||||
with open(file_path, "w", encoding="utf-8") as file:
|
||||
yaml.dump(nested_dict(self._config), file, allow_unicode=True)
|
||||
|
||||
def _check_rights(self, key, module_id, access_type="get"):
|
||||
return
|
||||
|
||||
def get(self, key: str):
|
||||
module_id = self._get_module_id()
|
||||
self._check_rights(key, module_id)
|
||||
return self._config.get(key, self._metadata.get(key).get("default_value"))
|
||||
|
||||
def get_meta(self, key: str):
|
||||
module_id = self._get_module_id()
|
||||
self._check_rights(key, module_id, "get_meta")
|
||||
return self._metadata.get(key)
|
||||
|
||||
def _get_module_id(self):
|
||||
caller_frame = inspect.currentframe().f_back.f_back
|
||||
caller_globals = caller_frame.f_globals
|
||||
module_id = caller_globals.get("__ocab_module_id__")
|
||||
return module_id
|
||||
|
||||
def mass_set(self, updates: Dict[str, Any]):
|
||||
module_id = self._get_module_id()
|
||||
for key, value in updates.items():
|
||||
self._check_rights(key, module_id, "set")
|
||||
if key in self._metadata:
|
||||
# TODO: add checks to validate the type and value based on metadata
|
||||
self._config[key] = value
|
||||
else:
|
||||
raise KeyError(f"Key {key} is not registered.")
|
||||
|
||||
def register(
|
||||
self,
|
||||
key: str,
|
||||
value_type: str,
|
||||
options: List[Any] = None,
|
||||
default_value=None,
|
||||
editable: bool = True,
|
||||
shared: bool = False,
|
||||
required: bool = False,
|
||||
visible: bool = True,
|
||||
pretty_name: str = "",
|
||||
description: str = "",
|
||||
):
|
||||
module_id = self._get_module_id()
|
||||
|
||||
self._check_rights(key, module_id, "register")
|
||||
|
||||
if key in self._metadata:
|
||||
raise ValueError("ERROR")
|
||||
|
||||
self._metadata[key] = {
|
||||
"type": value_type,
|
||||
"options": options,
|
||||
"default_value": default_value,
|
||||
"visible": visible,
|
||||
"editable": editable,
|
||||
"shared": shared,
|
||||
"required": required,
|
||||
"pretty_name": pretty_name,
|
||||
"description": description,
|
||||
"module_id": module_id,
|
||||
}
|
||||
+10
-1
@@ -5,5 +5,14 @@
|
||||
"author": "OCAB Team",
|
||||
"version": "1.0.0",
|
||||
"privileged": true,
|
||||
"dependencies": {}
|
||||
"dependencies": {
|
||||
"optional": {
|
||||
"standard.miniapp": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"pythonDependencies": {
|
||||
"optional": {
|
||||
"flet": "^0.23.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
from ocab_core.modules_system.public_api import get_module, log
|
||||
|
||||
from .config import config
|
||||
from .miniapp_ui import get_miniapp_blueprint
|
||||
|
||||
|
||||
def register_settings_page():
|
||||
try:
|
||||
register_page = get_module("standard.miniapp", "register_page")
|
||||
|
||||
prefix = "settings"
|
||||
|
||||
register_page(
|
||||
name="Настройки",
|
||||
path="/settings",
|
||||
blueprint=get_miniapp_blueprint(config, prefix),
|
||||
prefix=prefix,
|
||||
role="ADMIN",
|
||||
)
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
log(str(e))
|
||||
pass
|
||||
|
||||
|
||||
def module_late_init():
|
||||
register_settings_page()
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,266 @@
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .config_manager import ConfigManager
|
||||
|
||||
try:
|
||||
import dash_bootstrap_components as dbc
|
||||
import flask
|
||||
from dash_extensions.enrich import ALL, Input, Output, State, dcc, html
|
||||
|
||||
DASH_AVAILABLE = True
|
||||
except ImportError:
|
||||
DASH_AVAILABLE = False
|
||||
|
||||
from ocab_core.modules_system.public_api import get_module
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ocab_modules.standard.roles import Roles as IRoles
|
||||
|
||||
|
||||
def create_control(key: str, config: ConfigManager):
|
||||
value = config.get(key)
|
||||
meta = config.get_meta(key)
|
||||
|
||||
component_id = {
|
||||
"type": "setting",
|
||||
"key": key,
|
||||
}
|
||||
|
||||
label_text = meta.get("pretty_name") or key
|
||||
|
||||
if meta.get("type") in ["string", "int", "float", "password"]:
|
||||
input_type = {
|
||||
"string": "text",
|
||||
"int": "number",
|
||||
"float": "number",
|
||||
"password": "password",
|
||||
}.get(meta.get("type"), "text")
|
||||
|
||||
input_props = {
|
||||
"id": component_id,
|
||||
"type": input_type,
|
||||
}
|
||||
|
||||
if meta.get("type") != "password":
|
||||
input_props["value"] = value
|
||||
|
||||
if meta.get("type") == "int":
|
||||
input_props["step"] = 1
|
||||
input_props["pattern"] = r"\d+"
|
||||
elif meta.get("type") == "float":
|
||||
input_props["step"] = "any"
|
||||
|
||||
component = dbc.Input(**input_props, invalid=False)
|
||||
|
||||
elif meta.get("type") == "select":
|
||||
options = [{"label": opt, "value": opt} for opt in meta.get("options", [])]
|
||||
component = dcc.Dropdown(
|
||||
id=component_id,
|
||||
options=options,
|
||||
value=value,
|
||||
style={
|
||||
"padding-left": 0,
|
||||
"padding-right": 0,
|
||||
},
|
||||
)
|
||||
elif meta.get("type") == "checkbox":
|
||||
component = dbc.Checkbox(
|
||||
id=component_id,
|
||||
checked=value,
|
||||
label=label_text,
|
||||
)
|
||||
else:
|
||||
return None
|
||||
|
||||
row = []
|
||||
if meta.get("type") != "checkbox":
|
||||
row.append(dbc.Label(label_text))
|
||||
|
||||
row.append(component)
|
||||
|
||||
if meta.get("description"):
|
||||
row.append(dbc.FormText(meta.get("description")))
|
||||
|
||||
return dbc.Row(row, className="mb-3 mx-1")
|
||||
|
||||
|
||||
def build_settings_tree(config: ConfigManager):
|
||||
tree = {}
|
||||
|
||||
for key, value in config._metadata.items():
|
||||
if not value["visible"]:
|
||||
continue
|
||||
|
||||
parts = key.split("::")
|
||||
control = create_control(key, config)
|
||||
|
||||
current = tree
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if part not in current:
|
||||
current[part] = {"__controls": []}
|
||||
current = current[part]
|
||||
|
||||
current["__controls"].append(control)
|
||||
|
||||
return tree
|
||||
|
||||
|
||||
def create_card(category, controls):
|
||||
return dbc.Card(
|
||||
[
|
||||
dbc.CardHeader(html.H3(category, className="mb-0")),
|
||||
dbc.CardBody(controls),
|
||||
],
|
||||
className="mb-3",
|
||||
)
|
||||
|
||||
|
||||
def create_settings_components(tree, level=0):
|
||||
components = []
|
||||
|
||||
for category, subtree in tree.items():
|
||||
if category == "__controls":
|
||||
continue
|
||||
|
||||
controls = subtree.get("__controls", [])
|
||||
subcomponents = create_settings_components(subtree, level + 1)
|
||||
|
||||
if controls or subcomponents:
|
||||
card_content = controls + subcomponents
|
||||
card = create_card(category, card_content)
|
||||
components.append(card)
|
||||
|
||||
return components
|
||||
|
||||
|
||||
def get_miniapp_blueprint(config: ConfigManager, prefix: str):
|
||||
Roles: "type[IRoles]" = get_module("standard.roles", "Roles")
|
||||
|
||||
roles = Roles()
|
||||
|
||||
import datetime
|
||||
|
||||
from dash_extensions.enrich import DashBlueprint
|
||||
|
||||
bp = DashBlueprint()
|
||||
|
||||
def create_layout():
|
||||
settings_tree = build_settings_tree(config)
|
||||
settings_components = create_settings_components(settings_tree)
|
||||
|
||||
layout = html.Div(
|
||||
[
|
||||
html.Script(),
|
||||
html.H1("Настройки"),
|
||||
dbc.Form(settings_components),
|
||||
html.Div(id="save-confirmation"),
|
||||
dbc.Button(
|
||||
"Сохранить",
|
||||
id="save-settings",
|
||||
color="primary",
|
||||
className="mt-3 w-100",
|
||||
n_clicks=0,
|
||||
),
|
||||
html.Div(id="settings-update-trigger", style={"display": "none"}),
|
||||
dcc.Store(id="settings-store"),
|
||||
],
|
||||
style={
|
||||
"padding": "20px",
|
||||
},
|
||||
)
|
||||
return layout
|
||||
|
||||
bp.layout = create_layout
|
||||
|
||||
@bp.callback(
|
||||
Output("save-confirmation", "children"),
|
||||
Output("settings-store", "data"),
|
||||
Input("save-settings", "n_clicks"),
|
||||
State({"type": "setting", "key": ALL}, "value"),
|
||||
State({"type": "setting", "key": ALL}, "id"),
|
||||
prevent_initial_call=True,
|
||||
allow_duplicate=True,
|
||||
)
|
||||
def save_settings(n_clicks, values, keys):
|
||||
if n_clicks > 0:
|
||||
user = getattr(flask.g, "user", None)
|
||||
|
||||
if user is None:
|
||||
return (
|
||||
dbc.Alert(
|
||||
"Вы не авторизованы!",
|
||||
color="danger",
|
||||
duration=10000,
|
||||
),
|
||||
"-",
|
||||
)
|
||||
|
||||
if not asyncio.run(roles.check_admin_permission(user["id"])):
|
||||
return (
|
||||
dbc.Alert(
|
||||
"Вы не администратор!",
|
||||
color="danger",
|
||||
duration=10000,
|
||||
),
|
||||
"-",
|
||||
)
|
||||
|
||||
# TODO: добавить валидацию значений
|
||||
|
||||
updated_settings = {}
|
||||
for value, id_dict in zip(values, keys):
|
||||
key: str = id_dict["key"]
|
||||
if prefix:
|
||||
key = key.removeprefix(f"{prefix}-")
|
||||
|
||||
meta = config.get_meta(key)
|
||||
|
||||
if meta["type"] == "password":
|
||||
if value: # Only update if a new value is provided
|
||||
updated_settings[key] = value
|
||||
else:
|
||||
updated_settings[key] = value
|
||||
|
||||
config.mass_set(updated_settings)
|
||||
config.save()
|
||||
|
||||
now = datetime.datetime.now()
|
||||
date_str = now.strftime("%H:%M:%S")
|
||||
|
||||
return (
|
||||
dbc.Alert(
|
||||
f"Настройки сохранены в {date_str}",
|
||||
color="success",
|
||||
duration=10000,
|
||||
),
|
||||
date_str,
|
||||
)
|
||||
|
||||
bp.clientside_callback(
|
||||
"""
|
||||
function(n_clicks) {
|
||||
const buttonSelector = '#%s-save-settings';
|
||||
if (n_clicks > 0) {
|
||||
document.querySelector(buttonSelector).disabled = true;
|
||||
}
|
||||
}
|
||||
"""
|
||||
% (prefix),
|
||||
Input("save-settings", "n_clicks"),
|
||||
)
|
||||
|
||||
bp.clientside_callback(
|
||||
"""
|
||||
function(data) {
|
||||
const buttonSelector = '#%s-save-settings';
|
||||
if (data) {
|
||||
document.querySelector(buttonSelector).disabled = false;
|
||||
}
|
||||
}
|
||||
"""
|
||||
% (prefix),
|
||||
Input("settings-store", "data"),
|
||||
)
|
||||
|
||||
return bp
|
||||
+5
-12
@@ -1,9 +1,7 @@
|
||||
import peewee as pw
|
||||
from aiogram.types import Message
|
||||
|
||||
from src.service import paths
|
||||
|
||||
from .exceptions import MissingModuleName, NotExpectedModuleName
|
||||
from .exceptions import NotExpectedModuleName
|
||||
from .models.chat_stats import ChatStats
|
||||
from .models.chats import Chats
|
||||
from .models.db import database_proxy
|
||||
@@ -14,14 +12,9 @@ from .models.users import Users
|
||||
|
||||
|
||||
def connect_database(is_test: bool = False, module: str | None = None):
|
||||
if is_test:
|
||||
if not module:
|
||||
raise MissingModuleName()
|
||||
db_path = f"{paths.modules_standard}/{module}/tests/database"
|
||||
else:
|
||||
if module:
|
||||
raise NotExpectedModuleName()
|
||||
db_path = f"{paths.core}/database"
|
||||
if module:
|
||||
raise NotExpectedModuleName()
|
||||
db_path = "database"
|
||||
|
||||
database = pw.SqliteDatabase(f"{db_path}/OCAB.db")
|
||||
database_proxy.initialize(database)
|
||||
@@ -158,7 +151,7 @@ def get_user_name(user_id):
|
||||
return user.user_name if user else None
|
||||
|
||||
|
||||
def get_user_role(user_id):
|
||||
def get_user_role(user_id: str):
|
||||
user = Users.get_or_none(Users.id == user_id)
|
||||
return user.user_role if user else None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user