notify v0.4.0 [base]
Рассылка уведомлений администраторам Telegram (старт/стоп/транспорт).
| version | date | commit | файлов |
|---|---|---|---|
| 0.4.0 | 2026-09-17 | 46803aa3a927 | 5 |
README
# notify
Рассылка уведомлений администраторам Telegram.
Тип: Модуль. Категория: `base`. Зависимостей нет.
## Описание
Единая точка отправки «всем, кому положено». Модуль берёт право, находит всех его держателей среди пользователей Telegram и шлёт текст каждому. Получатели с флагом беззвучных уведомлений получают сообщения без звука.
Отдельные методы предусмотрены для системных событий: старт бота после готовности транспорта, останов, смена прав пользователя, переключение транспорта Telegram, потеря и восстановление связи.
Модули не шлют уведомления сами: они регистрируют свои подписки в общем реестре, а отправка идёт через этот модуль. Подпиской управляет владелец — включает, выключает и отвязывает свои уведомления из кабинета или меню.
## Возможности
- `send {text: str, right: str = 'receive_notifications'}` → число доставок держателям права
- `startup {message: str = 'Бот успешно запущен!'}` → уведомление о запуске (право `receive_notifications_status`)
- `shutdown {message: str = 'Бот успешно остановлен!'}` → уведомление об остановке (право `receive_notifications_status`)
- `rights_changed {key: str, right: str, value: bool}` → уведомить пользователя о смене его права
- `transport_switch {old: str, new: str, reason: str = ''}` → уведомление о переключении TG-транспорта (всегда беззвучно)
## Права
- `receive_notifications` — Базовое право рассылок (дефолт notify.send)
- `receive_notifications_status` — Уведомления старт/стоп/связь (модуль notify)
- `receive_notifications_status_edit` — Право менять себе уведомления статуса
- `receive_notifications_no_sound` — Общий флаг беззвучных уведомлений
## События шины
- Подписано: `connectivity.changed` (алерты о потере/восстановлении связи)
Манифест
{
"name": "notify",
"version": "0.4.0",
"category": "base",
"description": "Рассылка уведомлений администраторам Telegram (старт/стоп/транспорт).",
"requires": [],
"rights": [],
"api_methods": [],
"web_routes": [],
"commit": "46803aa3a927a3a188c2249f494e5ea5ecf92dc6",
"updated": "2026-09-17 12:54:21 +0300",
"integrity": "sha256:9ab65d4e3435065c3efc6555196c407971938b6613ee93649a0b228fe1b038b8",
"files": "[5 файлов — см. вкладки ниже]",
"note": "",
"wdesc": "",
"wbody": "",
"_extra": {
"api": "",
"events_emitted": "[]",
"events_subscribed": "",
"config_schema": "config.schema.json",
"env_example": ".env.example"
}
} Файлы и исходники
Дерево файлов
- · корень
- 2.7 КБ
- 0.0 КБ
- 6.5 КБ
- 3.2 КБ
- 5.0 КБ
Предпросмотр
Выберите файл в дереве выше — код откроется здесь.
Все исходники (.py) одним списком
engine.py 6.5 КБ
"""notify.engine — рассылки администраторам (чистая реализация + ctx).
Порт modules/notify.py: стартовые/стоп/транспорт-уведомления уходят
telegram-пользователям из прав, у которых выставлено соответствующее
receive_notifications* право (старый should_notify: право + флаг
receive_notifications_no_sound). Отправка — через
ctx.api transport_telegram.sender (активный транспорт).
"""
import logging
logger = logging.getLogger(__name__)
def _targets(ctx, right):
"""[(chat_id, no_sound)] telegram-получателей с правом right."""
out = []
try:
users = ctx.rights.known_users()
except Exception: # noqa: BLE001
return out
for qid in users:
try:
kind, _, num = (qid or "").partition(":")
if kind != "telegram" or not num.lstrip("-").isdigit():
continue
entry = ctx.rights.get_entry(qid) or {}
if not entry.get(right, False):
continue
out.append((int(num), bool(entry.get("receive_notifications_no_sound", False))))
except Exception: # noqa: BLE001
continue
return out
def _sender(ctx):
try:
return ctx.api.call("transport_telegram.sender")
except Exception as e: # noqa: BLE001
logger.warning("notify: нет sender: %s", e)
return None
def send(ctx, text, right="receive_notifications", silent_no_sound=True):
"""Разослать text всем telegram-админам с правом right.
Возвращает число доставок. Ошибки отправки — в лог, не исключение.
"""
sender = _sender(ctx)
if sender is None:
return 0
n = 0
for chat_id, no_sound in _targets(ctx, right):
try:
sender.send_message(chat_id=chat_id, text=text or "…",
disable_notification=bool(no_sound and silent_no_sound))
n += 1
except Exception as e: # noqa: BLE001
logger.error("notify %s: %s", chat_id, e)
return n
#: Динамический реестр подписок уведомлений для меню.
#: Владелец заявляет себя сам через notify.register (в своём start):
#: никакой статики по чужим модулям здесь нет. Порядок выдачи —
#: по right (детерминирован независимо от порядка загрузки).
_SUBSCRIPTIONS: dict = {}
_RIGHT_RX = None # ленивая компиляция (модуль без импорта re на старте)
def _right_rx():
global _RIGHT_RX
if _RIGHT_RX is None:
import re as _re
_RIGHT_RX = _re.compile(r"^receive_notifications_[a-z0-9_]{1,40}$")
return _RIGHT_RX
def register_subscription(label, right, edit_right="", module=""):
"""Заявить подписку уведомлений. Возвращает (ok, err).
label — подпись кнопки (1..40), right — receive_notifications_*,
edit_right — право на переключение себе (дефолт right + "_edit"),
module — имя модуля-владельца (для диагностики).
Повторная регистрация того же right — перезапись (идемпотентно).
"""
label = (label or "").strip()[:40]
right = (right or "").strip()
edit_right = (edit_right or "").strip() or (right + "_edit" if right else "")
module = (module or "").strip()[:32]
if not label:
return False, "пустой label"
if not _right_rx().match(right):
return False, "right должен быть receive_notifications_*"
if not _right_rx().match(edit_right):
return False, "edit_right должен быть receive_notifications_*"
_SUBSCRIPTIONS[right] = {"label": label, "right": right,
"edit_right": edit_right, "module": module}
return True, ""
def unregister_subscription(right):
"""Снять подписку. Возвращает True, если была."""
return _SUBSCRIPTIONS.pop((right or "").strip(), None) is not None
def list_subscriptions():
"""[{label, right, edit_right, module}] — по right (детерминированно)."""
return [dict(_SUBSCRIPTIONS[k]) for k in sorted(_SUBSCRIPTIONS)]
def toggle_map():
"""{callback_data: right} — производная от реестра."""
return {"toggle_notif:%s" % right: right
for right in sorted(_SUBSCRIPTIONS)}
def startup(ctx, message="Бот успешно запущен!"):
"""Уведомление о запуске (право receive_notifications_status)."""
return send(ctx, message, right="receive_notifications_status")
def rights_changed(ctx, key, right, value):
"""Уведомление пользователю о смене его права (как в проде).
Шлём напрямую затронутому telegram-пользователю, а не по
правам-рассылкам: право могли только что отобрать.
"""
kind, _, num = (key or "").partition(":")
if kind != "telegram" or not (num or "").lstrip("-").isdigit():
return 0
sender = _sender(ctx)
if sender is None:
return 0
text = "🔑 Право %s: %s" % (right, "выдано" if value else "отозвано")
try:
sender.send_message(chat_id=int(num), text=text,
disable_notification=False)
return 1
except Exception as e: # noqa: BLE001
logger.error("notify rights %s: %s", key, e)
return 0
def shutdown(ctx, message="Бот успешно остановлен!"):
"""Уведомление об остановке (право receive_notifications_status)."""
return send(ctx, message, right="receive_notifications_status")
def transport_switch(ctx, old, new, reason=""):
"""Уведомление о переключении TG-транспорта (всегда беззвучно)."""
return send(ctx, "🔁 Переключение Telegram-транспорта: %s -> %s\nПричина: %s"
% (old, new, reason or "—"),
right="receive_notifications_status", silent_no_sound=True) or True
module.py 5.0 КБ
"""notify.Module — рассылки администраторам Telegram.
Без TG-команд и веб-роутов: только ctx.api для соседей.
Старт — по on_ready транспорта (sender в cmd_run ещё не активен),
стоп — из boot.py. Права-получатели — из ctx.rights (receive_notifications*).
"""
import logging
from core.base_module import BaseModule
from . import engine as notify
logger = logging.getLogger(__name__)
class Module(BaseModule):
def setup(self, ctx) -> None:
self.ctx = ctx
self._startup_sent = False
ctx.api.register("notify", "send", lambda text, right="receive_notifications":
notify.send(ctx, text, right=right))
ctx.api.register("notify", "startup", lambda message="Бот успешно запущен!":
notify.startup(ctx, message))
ctx.api.register("notify", "shutdown", lambda message="Бот успешно остановлен!":
notify.shutdown(ctx, message))
ctx.api.register("notify", "rights_changed",
lambda key, right, value: notify.rights_changed(
ctx, key, right, value))
ctx.api.register(
"notify", "transport_switch",
lambda old, new, reason="": notify.transport_switch(ctx, old, new, reason))
ctx.api.register("notify", "register",
lambda label, right, edit_right="", module="":
notify.register_subscription(label, right, edit_right, module))
ctx.api.register("notify", "unregister",
lambda right: notify.unregister_subscription(right))
ctx.api.register("notify", "subscriptions",
lambda: notify.list_subscriptions())
ctx.api.register("notify", "toggle_map",
lambda: notify.toggle_map())
# Своя подписка (старт/стоп/связь) — как у всех, через тот же реестр.
notify.register_subscription(
"Status", "receive_notifications_status",
"receive_notifications_status_edit", "notify")
# Стартовое уведомление — по готовности транспорта (см. start()).
try:
ctx.events.subscribe("connectivity.changed", self._on_connectivity)
except Exception as e: # noqa: BLE001
logger.warning("notify subscribe: %s", e)
def start(self) -> None:
# Подписка здесь, а не в setup: api транспорта регистрируется
# в его setup, порядок setup'ов не гарантирован. К моменту
# start() все setup'ы выполнены, а активация транспорта —
# позже (фон supervisor), так что колбэк успевает.
import time as _time
self._start_ts = _time.time()
try:
self.ctx.api.call("transport_telegram.on_ready", self._on_tg_ready)
except Exception as e: # noqa: BLE001
logger.warning("notify on_ready: %s", e)
def _on_tg_ready(self, active_name):
if self._startup_sent:
return
self._startup_sent = True
try:
from datetime import datetime as _dt
when = _dt.fromtimestamp(getattr(self, "_start_ts", 0) or 0)
when_s = when.strftime("%d.%m %H:%M:%S") if getattr(
self, "_start_ts", 0) else "?"
n = notify.startup(
self.ctx,
"Бот успешно запущен!\nВремя запуска: %s\nТранспорт: %s"
% (when_s, active_name))
logger.info("notify startup: %d доставок (транспорт %s)",
n, active_name)
except Exception as e: # noqa: BLE001
logger.warning("notify startup: %s", e)
def _on_connectivity(self, payload):
"""Алерты о потере/восстановлении связи (право receive_notifications_status)."""
from datetime import datetime as _dt
try:
up = bool((payload or {}).get("up", True))
at = (payload or {}).get("at")
when = _dt.fromtimestamp(at).strftime("%Y-%m-%d %H:%M:%S") if at else "?"
text = ("🟢 Связь с Telegram восстановлена (%s)" % when) if up else (
"🔴 Потеряна связь с Telegram (%s)" % when)
notify.send(self.ctx, text, right="receive_notifications_status")
except Exception as e: # noqa: BLE001
logger.warning("notify connectivity: %s", e)
def health(self) -> dict:
try:
n = len(notify._targets(self.ctx, "receive_notifications_status"))
except Exception: # noqa: BLE001
n = -1
return {"ok": n >= 0, "module": self.name, "targets": n}