cookbook v1.0.0 [dev]
Эталонный модуль-справочник — показывает все ключевые паттерны разработки модулей bot_modular.
| version | date | commit | файлов |
|---|---|---|---|
| 1.0.0 | 2026-09-16 | 2d7d04615b31 | 12 |
README
# cookbook `1.0.0` — эталонный модуль-справочник bot_modular
Полный пример всех ключевых паттернов разработки модулей. Каждый файл модуля
прокомментирован и объясняет свой контракт с ядром.
## Жизненный цикл модуля
```
loader.discover() → сканирует modules/*/manifest.{yaml,json}
modstate.is_enabled() → default-off для новых (включить через modsafe)
loader.load_module() → импортирует module.py, создаёт Module()
├─ manifest.validate() → обязательные поля: name, version, description
├─ requires.resolve() → ждёт зависимости (requires: [...])
├─ rights.register() → регистрирует права из manifest.yaml
└─ module.setup(ctx) # ← регистрация API, событий, UI
transport.start() → после всех модулей
└─ module.start() # ← фоновые потоки, подключения
module.stop() # ← при shutdown
```
## Контракты вкладов
### 1. module.py — ядро модуля
```python
class Module(BaseModule):
def setup(self, ctx) -> None:
"""Однажды при загрузке. НЕ поднимаем потоки."""
self.config = ctx.module_config("cookbook") # defaults < data/env/*.env < overrides
self.cache = ctx.cache.ns("cookbook") # изолированный кеш
self.engine = Engine(self.config, self.cache, ctx) # бизнес-логика
# Регистрация API-методов (вызываются: ctx.api.call("cookbook.метод"))
ctx.api.register("cookbook", "ping", self._api_ping)
# Подписка на события (публикуются: ctx.events.emit("cookbook.tick"))
ctx.events.subscribe("cookbook.tick", self._on_tick)
def start(self) -> None:
"""После setup() всех модулей. Здесь — фоновые потоки."""
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
def stop(self) -> None:
"""При shutdown."""
self._stop_event.set()
self._thread.join(timeout=5)
def health(self) -> dict:
"""Для --check и панели мониторинга."""
return {"ok": True, "module": self.name, "counter": self.engine.count}
```
### 2. engine.py — бизнес-логика
Вынесена из module.py для разделения ответственности:
- module.py — жизненный цикл, регистрация
- engine.py — состояние, вычисления
```python
class Engine:
def __init__(self, config, cache, ctx):
self.config = config # ModuleConfig
self.cache = cache # CacheService.Namespace
self.ctx = ctx # CoreContext
@property
def count(self):
return self._count
@count.setter
def count(self, value):
self._count = value
self.cache.set("counter", {"count": value}) # автосохранение
```
### 3. ui_tg.py — Telegram-интерфейс
```python
# Команды бота (строка = публичная, словарь = полный контроль)
TG_COMMANDS = {
"ping": "проверить живость",
"counter": {"desc": "счётчик", "right": None, "menu": True},
}
# Кнопки модуля (колбэки: <модуль>:<action>)
TG_CALLBACKS = {
"increment": {"desc": "+1", "right": None},
}
# Кнопки /start (главное меню)
TG_MENU = [
{"id": "cookbook_main", "title": "📖 Cookbook", "callback": "counter"},
]
def handle_command(ctx, config, command, text, inv=None):
# inv = {"chat_id": int, "user_qid": "telegram:<id>"}
if command == "ping":
return "🟢 " + ctx.api.call("cookbook.ping")
return None
def handle_callback(ctx, config, action, args):
# args = {"chat_id", "message_id", "message_text", "user_qid"}
# Возврат: str (edit), ("edit", t), ("send", t), ("toast", t), None
if action == "increment":
return ("toast", "+1")
return None
```
### 4. ui_web.py — Web-интерфейс
```python
# REST API (путь обязан начинаться с /api/<mod>/)
ROUTES = [
("GET", "/api/cookbook/ping", "ping", {"right": None}),
("POST", "/api/cookbook/reset", "reset", {"right": None}),
("GET", "/api/cookbook/private", "private", {"right": "cookbook_private"}),
]
# Карточка в кабинете (/api/services)
SERVICE = {
"key": "cookbook", "icon": "📖", "title": "Cookbook",
"right": None, "phase": 1, "desc": "Эталон модуля",
}
# Панели мониторинга (/api/monitoring/layout)
MONITOR_PANELS = [
{"id": "cookbook_counter", "title": "Счётчик",
"api": "/api/cookbook/counter", "visibility": "public", "refresh_s": 5},
]
# Навигация шапки SPA (/api/nav)
NAV = [
{"id": "cookbook", "title": "📖 Cookbook", "view": "cookbook", "icon": "📖"},
]
def handle_api(ctx, config, method, req):
# req = {"http_method", "path", "query", "body", "files",
# "user_qid", "session", "remote_ip"}
from core.errors import UserError
if method == "ping":
return {"ok": True, "pong": True} # → 200
if method == "reset":
body = req.get("body") or {}
ctx.api.call("cookbook.reset")
return {"ok": True} # → 200
if method == "private":
# Право проверяется транспортом ДО handle_api
return {"ok": True, "secret": "доступ разрешён"}
raise UserError("неизвестный метод") # → 400
```
### 5. ui_ws.py — WebSocket + `web/` — кабинет модуля
```python
WS_PATH = "/ws/cookbook/ticker" # обязан начинаться с /ws/
Кабинет — два файла, подхватываются транспортом сами (в `app.js` ядра
ничего дописывать не надо):
- `web/view.html` — `<section id="view-<view>">` с разметкой;
- `web/view.js` — `loadFn` + саморгистрация:
`BotUI.registerService('<ключ SERVICE>', '<view>')` (карточка кабинета →
вьюха) и `BotUI.registerView('<view>', loadFn)`. Рендерер своей панели —
`BotUI.registerPanelRenderer('<id панели>', fn)` в том же файле.
from botmod_transport_web.ws import make_base as _make_base
BaseSocket = _make_base()
class WSSocket(BaseSocket):
RIGHT = "cookbook_manage" # право для подключения
def on_authed(self, sess):
# self.mod_ctx — CoreContext
# self.ws_sess — данные сессии
self.write_message(json.dumps({"type": "connected"}))
def on_message(self, raw):
msg = json.loads(raw)
if msg["type"] == "ping":
self.write_message(json.dumps({"type": "pong"}))
def on_close(self):
# Очистка
pass
```
### 6. manifest.yaml — манифест модуля
```yaml
name: cookbook # имя = имя папки (обязательно)
version: 1.0.0 # семантическое.version
category: dev # core | base | extra | dev
description: Эталонный модуль...
# API-методы для /api/registry
api:
- cookbook.ping
- cookbook.counter
# События
events_emitted:
- cookbook.tick
events_subscribed:
- cookbook.tick
# TG-команды (для справки)
tg_commands:
- ping
- counter
# Web-роуты (для справки)
web_routes:
- GET /api/cookbook/ping
- GET /api/cookbook/private (right cookbook_private)
# WS-пути (для справки)
ws_routes:
- /ws/cookbook/ticker
# Подавление ложных срабатываний аудита
audit_allow:
- NET_IMPORT
# Права модуля (декларация)
rights:
- name: cookbook_private
description: Доступ к приватному API
- name: cookbook_manage
description: Управление настройками
# Файлы
config_schema: config.schema.json
api_methods:
- method: ping
args: "{}"
returns: "str — 'pong'"
desc: Проверка живости
env_example: .env.example
```
### 7. config.schema.json — схема конфигурации
```json
{
"required": [],
"optional": {
"FACT_INTERVAL": "60",
"COUNTER_TICK_INTERVAL": "1"
}
}
```
Слои значений: `defaults` < `data/env/<name>.env` < `data/runtime_config.json`
### 8. cache.py — кеш-неймспейсы
```python
def counter_ns(ctx):
return ctx.cache.ns("cookbook") # data/cache/cookbook/*.json
```
Каждый модуль видит только свой namespace. Файловая блокировка flock.
## Взаимодействие с другими модулями
### Вызов API-метода
```python
result = ctx.api.call("cookbook.ping")
```
### Подписка на события
```python
ctx.events.subscribe("cookbook.tick", self._on_tick)
```
### Проверка права
```python
if ctx.rights.can("telegram:123", "cookbook_private"):
...
```
### Использование кеша
```python
ns = ctx.cache.ns("cookbook")
ns.set("key", {"data": "value"})
data = ns.get("key")
data = ns.get_ttl("key", 60) # с TTL
```
## Типы ошибок
```python
from core.errors import UserError, ConfigError, UpstreamError
# UserError → 400 (плохой ввод, показать пользователю)
raise UserError("неизвестный метод: %s" % method)
# ConfigError → fail-fast при setup (чинить руками)
raise ConfigError("нет обязательного ключа X")
# UpstreamError → 500 (апстрим недоступен, можно ретраить)
raise UpstreamError("ollama недоступен")
```
## Проверка модуля
```bash
# Проверка без запуска
python3 boot.py --check
# Список модулей
python3 boot.py --list-modules
# Вызов API-метода
python3 boot.py --call cookbook.ping
# Runtime override
python3 boot.py --set-override cookbook.FACT_INTERVAL 30
python3 boot.py --clear-override cookbook.FACT_INTERVAL
```
## Создание нового модуля
1. Скопировать `modules/cookbook/` в `modules/mynew/`
2. Переименовать все файлы (cookbook → mynew)
3. Обновить `manifest.yaml` (name, version, description)
4. Обновить `module.py` (имя класса, API-методы)
5. Обновить `ui_tg.py`, `ui_web.py`, `ui_ws.py`
6. Включить через modsafe (web/API)
7. `python3 boot.py --check`
## Зависимости между модулями
Поле `requires` в manifest.yaml:
```yaml
requires:
- access # загрузится после access
- models # загрузится после models
```
Зависимости разрешаются до setup(). Нет зависимости — модуль пропускается.
Циклы — тоже пропуск (без падения бота).
## Аудит модуля
Ядро сканирует файлы модуля по эвристикам:
- critical: eval, exec, compile, os.system, shell=True, pickle, marshal, ctypes
- high: yaml.load, shutil.rmtree, хардкод секретов, os.exec/popen/fork
- medium: динамический импорт, base64-декодирование, сетевые импорты
- low: чтение окружения, запись файлов
Подавление ложных срабатываний:
```yaml
audit_allow:
- NET_IMPORT # сетевой импорт легитимен
- SUBPROC # subprocess нужен для вызова внешних программ
```
Хеши файлов: baseline при первом запуске, дальше — detect изменений (changed/new/missing).
Манифест
{
"name": "cookbook",
"version": "1.0.0",
"category": "dev",
"description": "Эталонный модуль-справочник — показывает все ключевые паттерны разработки модулей bot_modular.",
"requires": [],
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "2d7d04615b31be9f5bbd91d5d61671182164bf39",
"updated": "2026-09-16 16:35:50 +0300",
"integrity": "sha256:c265cc1b2719960864c2d0bf6049fcc70e55ac01f9bad97d0a09962c8faf930a",
"tree_url": "http://10.10.10.30:3000/euk0r/bot_modular/src/branch/main/modules/cookbook",
"raw_url": "http://10.10.10.30:3000/euk0r/bot_modular/raw/branch/main/modules/cookbook/module.py",
"files": "[12 файлов — см. вкладки ниже]",
"note": "Эталонный модуль для примера",
"_extra": {
"api": "",
"events_emitted": "",
"events_subscribed": "",
"tg_commands": "",
"ws_routes": "",
"audit_allow": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 12.1 КБ
- 0.7 КБ
- 0.2 КБ
- 5.2 КБ
- 1.9 КБ
- 10.8 КБ
- 0.2 КБ
- 7.8 КБ
- 10.6 КБ
- 7.2 КБ
- web/
- 1.5 КБ
- 2.6 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
cache.py 0.7 КБ
"""cookbook.cache — кеш-неймспейсы модуля.
Каждый модуль видит только свой namespace: data/cache/cookbook/*.json.
Конкурентный доступ сериализуется flock.
Паттерн:
def my_ns(ctx):
return ctx.cache.ns("cookbook")
Использование:
ns = my_ns(ctx)
ns.set("key", {"data": "value"}) # атомарная запись (tmp + rename)
data = ns.get("key") # блокировка SH
data = ns.get_ttl("key", 60) # с TTL по mtime
"""
def counter_ns(ctx):
"""Кеш для счётчика (управляется Engine, но namespace тут)."""
return ctx.cache.ns("cookbook")
engine.py 5.2 КБ
"""cookbook.engine — бизнес-логика модуля.
Вынесена из module.py для разделения ответственности:
module.py — жизненный цикл, регистрация API/событий
engine.py — состояние, вычисления, взаимодействие с внешними системами
Движок получает config, cache и ctx в конструкторе.
Состояние хранится в cache (персистентно) и в памяти (для скорости).
"""
from __future__ import annotations
import random
import time
class Engine:
"""Состояние и бизнес-логика модуля cookbook."""
# --- Факты — эталонный список (замените на свои) ---
FACTS = [
"bot_modular использует PyYAML для манифестов модулей",
"Каждый модуль — отдельная папка с manifest.yaml и module.py",
"Модули общаются только через ctx — прямые импорты запрещены",
"Права проверяются через ctx.rights.can(qualified_id, right)",
"Кеш модулей изолирован: data/cache/<module>/<key>.json",
"Runtime overrides пишутся в data/runtime_config.json",
"Транспорт Telegram поддерживает http и kurigram с failover",
"Веб-панель на Tornado: SPA, WebSocket, авто-TLS",
"Статический аудит ловит eval, exec, pickle, обфускацию",
"Новые модули по умолчанию выключены (modsafe + default-off)",
]
def __init__(self, config, cache, ctx):
"""Инициализация движка.
Args:
config: ModuleConfig — конфигурация модуля (defaults < .env < overrides)
cache: CacheService.Namespace — кеш-неймспейс модуля
ctx: CoreContext — контекст ядра (api, events, rights, cache)
"""
self.config = config
self.cache = cache
self.ctx = ctx
# Счётчик — из кеша (персистентно) или 0
# Важно: started_at ДО count, т.к. setter count читает started_at
saved = cache.get("counter")
self.started_at = saved["started_at"] if saved else time.time()
self._count = saved["count"] if saved else 0
@property
def count(self) -> int:
"""Текущее значение счётчика."""
return self._count
@count.setter
def count(self, value: int) -> None:
self._count = value
# Автосохранение в кеш при каждом изменении.
self.cache.set("counter", {
"count": value,
"started_at": self.started_at,
})
def get_counter(self) -> dict:
"""Вернуть текущий счётчик."""
return {
"count": self.count,
"started_at": self.started_at,
}
def increment_counter(self) -> None:
"""Увеличить счётчик на 1."""
self.count += 1
def reset_counter(self) -> None:
"""Сбросить счётчик."""
self.count = 0
def get_random_fact(self) -> str:
"""Вернуть случайный факт из списка.
Если FACTS пуст — возвращаем DEFAULT_FACT из конфига.
"""
if self.FACTS:
return random.choice(self.FACTS)
return self.config.get("DEFAULT_FACT", "bot_modular")
def get_settings(self) -> dict:
"""Вернуть текущие настройки.
Включает: defaults, .env, runtime overrides.
"""
return {
"defaults": dict(self.config.defaults),
"env_values": {k: v for k, v in self.config.values.items()
if k in self.config.defaults},
"overrides": self.config.override_keys,
}
def set_setting(self, key: str, value: str) -> None:
"""Записать runtime override для ключа.
Ключ валидируется по схеме модуля — неизвестный ключ вызовет
ConfigError на уровне module.py (через ctx.runtime.set_override).
"""
from core.errors import ConfigError
allowed = set(self.config.required) | set(self.config.defaults)
if key not in allowed:
raise ConfigError(
"cookbook: ключ %s не входит в схему" % key
)
self.ctx.runtime.set_override("cookbook", key, value, allowed=allowed)
def on_tick(self, payload: dict | None = None) -> None:
"""Обработка события cookbook.tick.
Args:
payload: произвольные данные от издателя
"""
# Можно логировать, обновлять состояние, отправлять уведомления.
if payload:
pass # обработка payload
module.py 10.8 КБ
"""cookbook.Module — эталонный модуль-справочник.
Демонстрирует все ключевые паттерны разработки модулей bot_modular:
1. Жизненный цикл: setup → start → stop → health
2. Регистрация API-методов (ctx.api.register)
3. Подписка на события (ctx.events.subscribe)
4. Конфигурация (ctx.module_config)
5. Кеш-неймспейс (ctx.cache.ns)
6. Runtime overrides (ctx.runtime)
7. Взаимодействие с другими модулями (ctx.api.call)
8. Telegram UI (TG_COMMANDS + handle_command)
9. Web UI (ROUTES + handle_api)
10. WebSocket UI (WS_PATH + WSSocket)
11. Callback-кнопки (TG_CALLBACKS + handle_callback)
12. Меню /start (TG_MENU)
13. Сервис кабинета (SERVICE)
14. Панели мониторинга (MONITOR_PANELS)
15. Навигация SPA (NAV)
16. Права (manifest rights + ctx.rights.can)
17. Ошибки (UserError, BotError)
18. Фоновый цикл (start/stop с потоком)
Структура файлов модуля:
module.py — ядро: setup/start/stop/health, регистрация API
engine.py — бизнес-логика (вынесена из module.py)
ui_tg.py — Telegram-интерфейс (команды, callbacks, меню)
ui_web.py — Web-интерфейс (ROUTES + handle_api)
ui_ws.py — WebSocket (WS_PATH + WSSocket)
cache.py — кеш-неймспейсы модуля
.env.example — пример конфига
config.schema.json — схема конфигурации
manifest.yaml — манифест модуля
"""
from __future__ import annotations
import logging
from core.base_module import BaseModule
from core.errors import ConfigError
logger = logging.getLogger(__name__)
class Module(BaseModule):
name = "cookbook"
version = "1.0.0"
def setup(self, ctx) -> None:
"""Настраиваем модуль: конфиг, кеш, API, события, права.
setup() вызывается один раз при загрузке модуля загрузчиком (loader).
Здесь НЕ поднимаем фоновые потоки — только регистрация.
Ошибки конфигурации — бросаем ConfigError (бот пропустит модуль).
"""
# --- 3. Конфигурация модуля ---
# ctx.module_config() собирает: defaults < .env < runtime overrides.
# Валидация по config.schema.json: обязательные + опциональные ключи.
self.config = ctx.module_config("cookbook")
# --- 4. Кеш-неймспейс ---
# Каждый модуль видит только свой namespace: data/cache/cookbook/*.json.
# Конкурентный доступ сериализуется flock.
self.cache = self._setup_cache(ctx)
# --- 5. Инициализация бизнес-логики ---
# Движок содержит состояние и методы модуля.
self.engine = None
try:
from . import engine as cookbook_engine
self.engine = cookbook_engine.Engine(self.config, self.cache, ctx)
except ImportError as e:
raise ConfigError(
"cookbook: движок требует зависимости: %s" % e
) from None
# --- 1. Регистрация API-методов ---
# Другие модули вызывают: ctx.api.call("cookbook.метод", ...)
# Каждый метод документируется в __doc__ (для /api/registry).
ctx.api.register("cookbook", "ping", self._api_ping)
ctx.api.register("cookbook", "counter", self._api_counter)
ctx.api.register("cookbook", "reset", self._api_reset)
ctx.api.register("cookbook", "fact", self._api_fact)
ctx.api.register("cookbook", "settings_get", self._api_settings_get)
ctx.api.register("cookbook", "settings_set", self._api_settings_set)
# --- 6. Подписка на события ---
# Другие модули публикуют: ctx.events.emit("cookbook.tick", payload)
# Упавший подписчик не роняет остальных (dead-letter queue).
ctx.events.subscribe("cookbook.tick", self._on_tick)
# --- 7. Хук on_ready транспорта ---
# Вызывается при активации транспорта (первый poll/fetch).
# Можно отправить стартовое уведомление.
try:
ctx.api.call("transport_telegram.on_ready", self._on_tg_ready)
except Exception:
pass # транспорта ещё нет — не критично
def start(self) -> None:
"""Поднимаем фоновую активность (потоки, tasks).
start() вызывается после setup() всех модулей.
Здесь запускаем фоновые циклы, подключения, слушатели.
"""
if self.engine is None:
return
# --- 18. Фоновый цикл ---
# Запускаем поток для периодического тика счётчика.
import threading
self._stop_event = threading.Event()
self._thread = threading.Thread(
target=self._ticker_loop,
name="cookbook:ticker",
daemon=True,
)
self._thread.start()
def stop(self) -> None:
"""Останавливаем фоновую активность."""
if hasattr(self, "_stop_event"):
self._stop_event.set()
if hasattr(self, "_thread") and self._thread is not None:
self._thread.join(timeout=5)
def health(self) -> dict:
"""Состояние модуля для мониторинга.
Возвращается при --check и читается панелью мониторинга.
"""
return {
"ok": True,
"module": self.name,
"api": [
"cookbook.ping",
"cookbook.counter",
"cookbook.reset",
"cookbook.fact",
"cookbook.settings_get",
"cookbook.settings_set",
],
"counter": self.engine.count if self.engine else 0,
"thread_alive": getattr(self, "_thread", None) is not None
and self._thread.is_alive(),
}
# --- API-методы (вызываются через ctx.api.call) ---
def _api_ping(self) -> str:
"""cookbook.ping: проверка живости модуля."""
return "pong"
def _api_counter(self) -> dict:
"""cookbook.counter: текущий счётчик."""
if self.engine is None:
from core.errors import UserError
raise UserError("модуль не инициализирован")
return self.engine.get_counter()
def _api_reset(self) -> dict:
"""cookbook.reset: сбросить счётчик."""
if self.engine is None:
from core.errors import UserError
raise UserError("модуль не инициализирован")
self.engine.reset_counter()
return {"ok": True, "reset_to": 0}
def _api_fact(self) -> str:
"""cookbook.fact: случайный факт из списка."""
if self.engine is None:
from core.errors import UserError
raise UserError("модуль не инициализирован")
return self.engine.get_random_fact()
def _api_settings_get(self) -> dict:
"""cookbook.settings_get: прочитать настройки."""
if self.engine is None:
from core.errors import UserError
raise UserError("модуль не инициализирован")
return self.engine.get_settings()
def _api_settings_set(self, key: str, value: str) -> dict:
"""cookbook.settings_set: записать настройку (runtime override).
Аргументы передаются как именованные параметры lambda.
Валидация ключа — по схеме модуля.
"""
if self.engine is None:
from core.errors import UserError
raise UserError("модуль не инициализирован")
self.engine.set_setting(key, value)
return {"ok": True, "key": key}
# --- Обработчики событий ---
def _on_tick(self, payload):
"""Обработка события cookbook.tick.
payload — произвольные данные от издателя.
Возвращаемое значение игнорируется (но может быть использовано).
"""
if self.engine is None:
return None
self.engine.on_tick(payload)
return "tick handled"
# --- Фоновый цикл ---
def _ticker_loop(self):
"""Фоновый поток: периодический тик счётчика + публикация события."""
import time
import logging
log = logging.getLogger("botmod.cookbook.ticker")
while not self._stop_event.is_set():
try:
if self.engine is not None:
self.engine.increment_counter()
# Публикуем событие для всех подписчиков.
self.ctx.events.emit("cookbook.tick", {
"source": "ticker",
"count": self.engine.count,
})
except Exception as e:
log.error("ticker error: %s", e)
# Спим по интервалу из конфига (по умолчанию 1 сек).
interval = float(self.config.get("COUNTER_TICK_INTERVAL", "1") or 1)
self._stop_event.wait(timeout=interval)
# --- Хуки транспорта ---
def _on_tg_ready(self, transport_name: str):
"""Вызывается при активации Telegram-транспорта."""
# Можно отправить стартовое сообщение в админ-чат.
# Пример: ctx.api.call("transport_telegram.send", chat_id, "cookbook ready")
pass
# --- Вспомогательные методы ---
def _setup_cache(self, ctx):
"""Инициализация кеш-неймспейса модуля."""
return ctx.cache.ns("cookbook")
ui_tg.py 7.8 КБ
"""cookbook.ui_tg — Telegram-интерфейс модуля.
Демонстрирует все паттерны TG-интерфейса:
1. TG_COMMANDS — команды бота (простая строка или словарь с meta)
2. TG_CALLBACKS — кнопки модуля (колбэки с паттерном <модуль>:<action>)
3. TG_MENU — кнопки /start (главное меню модуля)
4. handle_command — обработчик команд
5. handle_callback — обработчик кнопок
Контракт:
TG_COMMANDS = {
"command": "описание" # короткая форма = публичная, в меню
"command2": {"desc": "...", "right": "some_right", "menu": True/False},
}
TG_CALLBACKS = {
"action": "описание"
}
TG_MENU = [
{"id": "btn_id", "title": "Кнопка", "callback": "action", "right": "..."},
]
def handle_command(ctx, config, command, text, inv) -> str | None:
# ctx — CoreContext, config — ModuleConfig
# command — строка команды, text — аргументы после команды
# inv = {"chat_id", "user_qid"}
# Возврат: строка (ответ) или None (без ответа)
...
def handle_callback(ctx, config, action, args) -> str | tuple | None:
# action — строка из TG_CALLBACKS (без префикса модуля)
# args = {"chat_id", "message_id", "message_text", "user_qid"}
# Возврат:
# str — отредактировать сообщение с ответом
# ("edit", t) — то же самое (явный формат)
# ("send", t) — новое сообщение в чат
# ("toast", t) — всплывашка (answer) без правки сообщения
# None — ничего не делать
...
"""
# --- Команды бота ---
# Простая строка = {"desc": строка, "right": None, "menu": True}
# Словарь = полный контроль над поведением.
TG_COMMANDS = {
"ping": "проверить живость модуля",
"counter": {
"desc": "текущий счётчик + кнопки",
"right": None, # None = доступна всем
"menu": True, # True = показать в /start
},
"reset": "сбросить счётчик",
"fact": "получить случайный факт о bot_modular",
}
# --- Кнопки модуля (колбэки) ---
# Паттерн колбэка: <модуль>:<action> (макс. 64 байта)
TG_CALLBACKS = {
"increment": {"desc": "+1 к счётчику", "right": None},
"decrement": {"desc": "-1 к счётчику", "right": None},
"settings": {"desc": "настройки модуля", "right": "cookbook_manage"},
}
# --- Кнопки /start (главное меню модуля) ---
# Каждая кнопка: id, title, callback, право (опционально).
# Транспорт собирает кнопки всех модулей в одно меню.
# wide=True = кнопка на всю ширину. hidden=True = скрыта без права.
TG_MENU = [
{
"id": "cookbook_main",
"title": "📖 Cookbook",
"callback": "counter",
"right": None,
},
{
"id": "cookbook_settings",
"title": "⚙️ Настройки",
"callback": "settings",
"right": "cookbook_manage",
},
]
def handle_command(ctx, config, command: str, text: str, inv=None):
"""Обработчик Telegram-команд.
Args:
ctx: CoreContext — контекст ядра (api, events, rights, cache)
config: ModuleConfig — конфигурация модуля
command: str — имя команды (без /)
text: str — аргументы после команды
inv: dict | None — {"chat_id": int, "user_qid": str}
Returns:
str — текст ответа (разбивается на чанки по 4096 символов)
None — без ответа
"""
if command == "ping":
# --- Вызов API-метода другого модуля ---
# ctx.api.call("модуль.метод", *args) — синхронный вызов.
# Неизвестный метод → UserError (400), а не крах.
try:
pong = ctx.api.call("cookbook.ping")
return "🟢 %s" % pong
except Exception as e:
return "❌ cookbook.ping failed: %s" % e
if command == "counter":
# --- Счётчик с кнопками ---
# Callback-кнопки создаются через InlineKeyboardButton.
# Паттерн: "<модуль>:<action>" — транспорт роутит в handle_callback.
try:
data = ctx.api.call("cookbook.counter")
count = data.get("count", "?")
lines = [
"📊 Счётчик cookbook",
" count: %d" % count,
"",
"Кнопки: [ +1 ] [ -1 ]",
]
return "\n".join(lines)
except Exception as e:
return "❌ %s" % e
if command == "reset":
# --- Сброс счётчика ---
try:
ctx.api.call("cookbook.reset")
return "🔄 Счётчик сброшен"
except Exception as e:
return "❌ %s" % e
if command == "fact":
# --- Случайный факт ---
try:
fact = ctx.api.call("cookbook.fact")
return "📚 %s" % fact
except Exception as e:
return "❌ %s" % e
# Неизвестная команда — None = без ответа (транспорт уже показал 400).
return None
def handle_callback(ctx, config, action: str, args: dict):
"""Обработчик callback-кнопок модуля.
Args:
ctx: CoreContext
config: ModuleConfig
action: str — имя действия (из TG_CALLBACKS, без префикса модуля)
args: dict — {"chat_id", "message_id", "message_text", "user_qid"}
Returns:
str | tuple | None:
str — отредактировать сообщение
("edit", t) — то же самое
("send", t) — новое сообщение
("toast", t) — всплывашка (answer)
None — ничего
"""
if action == "increment":
# --- Увеличить счётчик ---
try:
data = ctx.api.call("cookbook.counter")
new_count = data["count"] + 1
return ("toast", "+1 → %d" % new_count)
except Exception as e:
return ("toast", "Ошибка: %s" % e)
if action == "decrement":
# --- Уменьшить счётчик ---
try:
data = ctx.api.call("cookbook.counter")
new_count = max(0, data["count"] - 1)
return ("toast", "-1 → %d" % new_count)
except Exception as e:
return ("toast", "Ошибка: %s" % e)
if action == "settings":
# --- Настройки (кнопка ⚙️ из TG_MENU) ---
try:
data = ctx.api.call("cookbook.settings_get")
lines = ["⚙️ Настройки cookbook:"]
for k in sorted(set(list(data.get("defaults") or {})
+ list(data.get("overrides") or []))):
lines.append(" %s = %s" % (k, (data.get("overrides") or {}).get(k, "(default)")))
return "\n".join(lines)
except Exception as e:
return ("toast", "Ошибка: %s" % e)
return None
ui_web.py 10.6 КБ
"""cookbook.ui_web — Web-интерфейс модуля.
Демонстрирует все паттерны Web-интерфейса:
1. ROUTES — REST API модуля
2. SERVICE — карточка в кабинете (каталог сервисов /api/services)
3. PANELS — витрина для SPA (этап WEB-3)
4. MONITOR_PANELS — карточка в панели мониторинга
5. NAV — пункт навигации шапки SPA
6. handle_api — обработчик API-запросов
Контракт ROUTES:
ROUTES = [
(
"GET", # HTTP-метод
"/api/cookbook/ping", # путь (обязан начинаться с /api/<mod>/)
"ping", # имя метода в handle_api
{ # метаданные (опционально)
"right": None, # None = публичный, "cookbook_private" = gated
"desc": "Проверка живости", # описание для /api/docs
},
),
]
handle_api(ctx, config, method, req) -> dict | (status, obj) | (status, obj, extra)
ctx: CoreContext
config: ModuleConfig | None
method: str — имя метода (третий элемент ROUTES)
req: dict — {
"http_method": "GET"/"POST"/...
"path": "/api/cookbook/ping"
"query": {"k": "v"} # из ?k=v
"body": {...} # JSON из тела (POST/PUT)
"files": {...} # multipart/form-data
"user_qid": "web:123" | None
"session": {...} # данные сессии (токен/кука)
"remote_ip": "..."
}
Возврат:
dict/list/str → 200 OK, JSON
(status, obj) → свой HTTP-статус
(status, obj, extra) → extra = {"set_cookies": [...], "clear_cookies": [...]}
UserError → 400 {"ok": false, "error": "..."}
BotError → 500 {"ok": false, "error": "internal"}
Правила изоляции:
- Путь обязан начинаться с /api/cookbook/ (иначе роут отклоняется)
- Право обязано быть декларировано в manifest.yaml (иначе warning)
"""
# --- REST API модуля ---
ROUTES = [
# Публичные роуты (right=None)
("GET", "/api/cookbook/ping", "ping", {"right": None, "desc": "Проверка живости модуля"}),
("GET", "/api/cookbook/counter", "counter", {"right": None, "desc": "Текущий счётчик"}),
("GET", "/api/cookbook/fact", "fact", {"right": None, "desc": "Случайный факт"}),
("GET", "/api/cookbook/health", "health", {"right": None, "desc": "Здоровье для панели мониторинга"}),
# Gated роуты (требуют право)
("POST", "/api/cookbook/reset", "reset", {"right": None, "desc": "Сбросить счётчик"}),
("GET", "/api/cookbook/settings", "settings_get", {"right": "cookbook_manage", "desc": "Настройки модуля"}),
("POST", "/api/cookbook/settings", "settings_set", {"right": "cookbook_manage", "desc": "Записать настройку {key, value}"}),
# Приватный роут (требует cookbook_private)
("GET", "/api/cookbook/private", "private", {"right": "cookbook_private", "desc": "Приватный endpoint (cookbook_private)"}),
]
# --- Карточки кабинета (каталог /api/services) ---
# SERVICE может быть dict или list dict.
# key — уникальный ключ, icon — эмодзи, phase — этап готовности (1 = готов).
SERVICE = [
{
"key": "cookbook",
"icon": "📖",
"title": "Cookbook — эталон модуля",
"right": None,
"phase": 1,
"desc": "Эталонный модуль-справочник: все паттерны разработки",
},
]
# --- Витрина для SPA (устарело: панели мониторинга — через MONITOR_PANELS) ---
PANELS = []
# --- Панели мониторинга ---
# Монтируются в общую сетку мониторинга (GET /api/monitoring/panels).
# visibility: "public" (все) | "auth" (вошедшие) | "right:<право>".
# refresh_s — интервал обновления в секундах.
MONITOR_PANELS = [
{
"id": "cookbook_counter",
"title": "Счётчик cookbook",
"api": "/api/cookbook/counter",
"visibility": "public",
"refresh_s": 5,
},
{
"id": "cookbook_health",
"title": "Здоровье cookbook",
"api": "/api/cookbook/health",
"visibility": "auth",
"refresh_s": 15,
},
]
# --- Навигация шапки SPA ---
# Пункты меню в верхней панели (GET /api/nav).
# view — имя вьюхи SPA (showView), href — внешняя ссылка.
# Одно из view/href обязательно. right — gated-навигация.
NAV = [
{
"id": "cookbook",
"title": "📖 Cookbook",
"view": "cookbook",
"icon": "📖",
"right": None, # None = всем, "cookbook_manage" = только с правом
},
]
def handle_api(ctx, config, method: str, req: dict):
"""Обработчик API-запросов модуля.
Единая точка для всех ROUTES. Диспетчеризация по имени метода.
Args:
ctx: CoreContext — контекст ядра
config: ModuleConfig | None — конфигурация модуля
method: str — имя метода (из ROUTES)
req: dict — данные запроса
Returns:
dict | (status, obj) | (status, obj, extra)
"""
# --- Импорт внутри функции против циклических импортов ---
from core.errors import UserError
# --- Аутентификация и права ---
# req["session"] — данные сессии (из Bearer/cookie)
# req["user_qid"] — qualified_id (web:123 или None)
sess = req.get("session") or {}
user_qid = req.get("user_qid")
# --- Проверка права через API транспорта ---
# ctx.api.call("transport_web.check", sess, right) — стандартный паттерн.
# Можно также: ctx.rights.can(user_qid, right)
def _has_right(right_key: str) -> bool:
"""Проверить право пользователя."""
if not sess:
return False
try:
return bool(ctx.api.call("transport_web.check", sess, right_key))
except Exception:
return False
# --- Методы ---
if method == "ping":
# Простейший ответ — dict → 200 OK
return {"ok": True, "module": "cookbook", "pong": "pong"}
if method == "counter":
# Вызов API-метода другого модуля
try:
data = ctx.api.call("cookbook.counter")
return {"ok": True, "counter": data}
except Exception as e:
return 500, {"ok": False, "error": str(e)[:400]}
if method == "reset":
# POST — читаем body
body = req.get("body") or {}
# Можно валидировать входные данные
force = body.get("force", False)
if not isinstance(force, bool):
return 400, {"ok": False, "error": "force должен быть boolean"}
try:
ctx.api.call("cookbook.reset")
return {"ok": True, "message": "Счётчик сброшен"}
except Exception as e:
return 500, {"ok": False, "error": str(e)[:400]}
if method == "fact":
# Простой API
try:
fact = ctx.api.call("cookbook.fact")
return {"ok": True, "fact": fact}
except Exception as e:
return 500, {"ok": False, "error": str(e)[:400]}
if method == "settings_get":
# Gated — проверяем право
if not _has_right("cookbook_manage"):
return 403, {"ok": False, "error": "forbidden", "right": "cookbook_manage"}
try:
settings = ctx.api.call("cookbook.settings_get")
return {"ok": True, "settings": settings}
except Exception as e:
return 500, {"ok": False, "error": str(e)[:400]}
if method == "settings_set":
# Gated + валидация body
if not _has_right("cookbook_manage"):
return 403, {"ok": False, "error": "forbidden", "right": "cookbook_manage"}
body = req.get("body") or {}
key = (body.get("key") or "").strip()
value = (body.get("value") or "").strip()
if not key or not value:
return 400, {"ok": False, "error": "нужны key и value"}
try:
ctx.api.call("cookbook.settings_set", key, value)
return {"ok": True, "key": key, "message": "Настройка обновлена"}
except UserError as e:
# UserError → 400 (автоматически, но здесь ловим явно)
return 400, {"ok": False, "error": str(e)}
except Exception as e:
return 500, {"ok": False, "error": str(e)[:400]}
if method == "private":
# Приватный роут — только для пользователей с cookbook_private
# Право проверяется транспортом ДО вызова handle_api (см. collect.py).
# Здесь — дополнительная проверка для примера.
if not _has_right("cookbook_private"):
return 403, {"ok": False, "error": "forbidden", "right": "cookbook_private"}
return {
"ok": True,
"message": "Вы добрались до приватного endpoint!",
"user": user_qid,
}
if method == "health":
# Для панели мониторинга (MONITOR_PANELS ниже ссылается сюда).
# Право — по панели (visibility), сам роут публичный.
try:
data = ctx.api.call("cookbook.counter")
return {"ok": True, "count": data.get("count", 0)}
except Exception as e:
return 500, {"ok": False, "error": str(e)[:400]}
# Неизвестный метод — UserError → 400
raise UserError("неизвестный метод: %s" % method)
ui_ws.py 7.2 КБ
'''cookbook.ui_ws — WebSocket-интерфейс модуля.
Демонстрирует паттерн WebSocket для модуля:
WS_PATH = "/ws/cookbook/ticker" # путь (обязан начинаться с /ws/)
class WSSocket(BaseSocket) # наследник базы транспорта
Контракт:
WS_PATH = "/ws/<namespace>/<name>"
# Путь обязан начинаться с /ws/ и быть задекларирован в manifest.yaml
# ws_routes: ["/ws/cookbook/ticker"]
class WSSocket(BaseSocket):
RIGHT = "cookbook_manage" # право для подключения (None = всем)
def on_authed(self, sess):
# Точка входа после успешной аутентификации
# self.mod_ctx — CoreContext
# self.ws_sess — данные сессии (из ticket/Bearer/cookie)
# self.write_message() — отправить сообщение
# self.close(code, reason) — закрыть соединение
...
def on_message(self, raw: str):
# Обработка входящего сообщения (JSON или текст)
# raw — строка от клиента
...
def on_close(self):
# Очистка при закрытии
...
BaseSocket (из transport_web.ws):
- check_origin() — проверка origin (same-host по умолчанию)
- ws_session() — ticket -> Bearer -> cookie
- ws_has_right(key) — проверка права
- write_message() — отправить текст/JSON
- close(code, reason) — закрыть соединение
'''
# --- Путь WebSocket (декларируется в manifest.yaml: ws_routes) ---
WS_PATH = "/ws/cookbook/ticker"
# --- Импортируем базовый класс из транспорта ---
from botmod_transport_web.ws import make_base as _make_base
BaseSocket = _make_base()
# --- Состояние сессий (ключ: "web:<uid>") ---
_sessions = {}
class WSSocket(BaseSocket):
"""WebSocket-сокет для стриминга тиков счётчика."""
# Право, проверяемое при open и перед каждым запросом.
# None = всем, "cookbook_manage" = только с правом.
RIGHT = "cookbook_manage"
def on_authed(self, sess):
"""Подключение установлено, пользователь аутентифицирован.
Точка входа для инициализации сессии:
- self.mod_ctx — CoreContext
- self.ws_sess — данные сессии (из ticket/Bearer/cookie)
- self.write_message() — отправить сообщение клиенту
Открываем поток-читатель для стриминга тиков.
"""
import json
import threading
self.ws_uid = sess["uid"]
self._stop = False
# Регистрируем сессию
key = "web:%s" % self.ws_uid
_sessions[key] = self
# Отправляем приветствие
self.write_message(json.dumps({
"type": "connected",
"uid": self.ws_uid,
"message": "📖 cookbook ticker подключён",
}, ensure_ascii=False))
# Запускаем поток для стриминга тиков
self._stream_thread = threading.Thread(
target=self._ticker_stream,
daemon=True,
name="cookbook:ws_stream",
)
self._stream_thread.start()
def _ticker_stream(self):
"""Фоновый поток: стриминг тиков счётчика."""
import json
import time
import logging
log = logging.getLogger("botmod.cookbook.ws")
while not self._stop:
try:
# Читаем текущий счётчик через API
data = self.mod_ctx.api.call("cookbook.counter")
count = data.get("count", 0)
self.write_message(json.dumps({
"type": "tick",
"count": count,
}, ensure_ascii=False))
except Exception as e:
log.error("ticker stream error: %s", e)
break
# Стримим каждую секунду
time.sleep(1)
def on_message(self, raw: str):
"""Обработка входящих сообщений от клиента.
Клиент может отправить:
{"type": "ping"} → ответим {"type": "pong"}
{"type": "subscribe"} → начнём стриминг (если не запущен)
{"type": "unsubscribe"} → остановим стриминг
любой текст → эхо
raw — строка от клиента (JSON или текст).
"""
import json
try:
msg = json.loads(raw)
except (json.JSONDecodeError, ValueError):
# Не JSON — отправляем как есть (эхо)
self.write_message(raw)
return
msg_type = msg.get("type", "")
if msg_type == "ping":
# Pong - проверка связи
import time as _time
self.write_message(json.dumps({
"type": "pong",
"timestamp": _time.time(),
}, ensure_ascii=False))
elif msg_type == "subscribe":
# Стриминг уже запущен в on_authed — подтверждаем
self.write_message(json.dumps({
"type": "subscribed",
"message": "Тик-стриминг активен",
}, ensure_ascii=False))
elif msg_type == "unsubscribe":
# Останавливаем стриминг (в on_close всё равно остановится)
self.write_message(json.dumps({
"type": "unsubscribed",
}, ensure_ascii=False))
elif msg_type == "reset":
# Сброс счётчика через API
try:
self.mod_ctx.api.call("cookbook.reset")
self.write_message(json.dumps({
"type": "reset_ok",
}, ensure_ascii=False))
except Exception as e:
self.write_message(json.dumps({
"type": "error",
"message": str(e)[:200],
}, ensure_ascii=False))
else:
# Неизвестный тип — ошибка
self.write_message(json.dumps({
"type": "error",
"message": "неизвестный тип сообщения: %s" % msg_type,
}, ensure_ascii=False))
def on_close(self):
"""Очистка при закрытии WebSocket."""
import logging
key = "web:%s" % getattr(self, "ws_uid", "?")
_sessions.pop(key, None)
self._stop = True
logging.getLogger("botmod.cookbook.ws").info(
"WS ticker закрыт: %s", key
)