Merge branch 'pr/68' into 0.8.0

This commit is contained in:
Ilay
2026-04-09 18:19:34 +05:00
9 changed files with 543 additions and 25 deletions

View File

@@ -27,6 +27,19 @@ def get_default_notifications() -> dict[str, bool]:
return {**system_keys, **user_keys}
@dataclass(kw_only=True)
class SystemNotifyRouteDto:
"""Routing target for a specific system notification type."""
chat_id: int
thread_id: Optional[int] = None
@property
def effective_thread_id(self) -> Optional[int]:
"""thread_id=1 is the General topic — treat as no-topic."""
return None if self.thread_id == 1 else self.thread_id
@dataclass(kw_only=True)
class AccessSettingsDto(TrackableMixin):
mode: AccessMode = AccessMode.PUBLIC
@@ -67,6 +80,7 @@ class RequirementSettingsDto(TrackableMixin):
@dataclass(kw_only=True)
class NotificationsSettingsDto(TrackableMixin):
settings: dict[str, bool] = field(default_factory=get_default_notifications)
routes: dict[str, SystemNotifyRouteDto] = field(default_factory=dict)
def is_enabled(self, ntf_type: NotificationType) -> bool:
return self.settings.get(ntf_type, True)
@@ -76,6 +90,19 @@ class NotificationsSettingsDto(TrackableMixin):
new_settings[ntf_type] = not self.is_enabled(ntf_type)
self.settings = new_settings
def get_route(self, ntf_type: NotificationType) -> Optional[SystemNotifyRouteDto]:
return self.routes.get(str(ntf_type))
def set_route(self, ntf_type: NotificationType, chat_id: int, thread_id: Optional[int]) -> None:
new_routes = self.routes.copy()
new_routes[str(ntf_type)] = SystemNotifyRouteDto(chat_id=chat_id, thread_id=thread_id)
self.routes = new_routes
def clear_route(self, ntf_type: NotificationType) -> None:
new_routes = self.routes.copy()
new_routes.pop(str(ntf_type), None)
self.routes = new_routes
@property
def system(self) -> list[tuple[str, bool]]:
return [

View File

@@ -29,6 +29,8 @@ from src.application.dto import (
TempUserDto,
UserDto,
)
from src.application.dto.message_payload import MediaDescriptorDto
from src.application.dto.settings import SystemNotifyRouteDto
from src.application.events import ErrorEvent, RemnawaveVersionWarningEvent, SystemEvent
from src.application.events.base import UserEvent
from src.application.events.system import RemnashopWelcomeEvent
@@ -101,7 +103,11 @@ class NotificationService(Notifier):
logger.info(f"Notification for '{event.notification_type}' is disabled, skipping")
return
await self.notify_admins(event.as_payload(), roles=[Role.OWNER, Role.DEV])
await self._notify_system(
event.as_payload(),
roles=[Role.OWNER, Role.DEV],
notification_type=event.notification_type,
)
@on_event(UserEvent)
async def on_user_event(self, event: UserEvent) -> None:
@@ -123,7 +129,7 @@ class NotificationService(Notifier):
logger.info(f"Notification for '{event.notification_type}' is disabled, skipping")
return
await self.notify_admins(event.as_payload())
await self._notify_system(event.as_payload(), notification_type=event.notification_type)
@on_event(ErrorEvent)
async def on_error_event(self, event: ErrorEvent) -> None:
@@ -146,11 +152,70 @@ class NotificationService(Notifier):
filename=f"error_{event.event_id}.txt",
)
await self.notify_admins(
await self._notify_system(
event.as_payload(media, error_type, error_message),
roles=[Role.OWNER, Role.DEV],
notification_type=event.notification_type,
)
async def _notify_system(
self,
payload: MessagePayloadDto,
roles: list[Role] = [Role.OWNER, Role.DEV, Role.ADMIN],
notification_type: Optional[str] = None,
) -> None:
"""Route system notification: to group/topic if configured in settings, else to admin chats."""
route = None
if notification_type:
settings: SettingsDto = await self.settings_dao.get()
route = settings.notifications.get_route(notification_type)
if route:
await self._send_to_topic(payload, route)
else:
await self.notify_admins(payload, roles=roles)
async def _send_to_topic(self, payload: MessagePayloadDto, route: SystemNotifyRouteDto) -> None:
"""Send a system notification to the configured group chat / topic."""
chat_id = route.chat_id
thread_id = route.effective_thread_id
locale = self.config.default_locale
text = self._get_translated_text(
locale=locale,
i18n_key=payload.i18n_key,
i18n_kwargs=payload.i18n_kwargs,
)
try:
if payload.is_text:
await self.bot.send_message(
chat_id=chat_id,
text=text,
message_thread_id=thread_id,
disable_web_page_preview=True,
disable_notification=payload.disable_notification,
)
elif payload.media:
method = self._get_media_method(payload)
if not method:
logger.warning(f"Unknown media type for topic payload '{payload}'")
return
media = self._build_media(payload.media)
await method(
chat_id,
media,
caption=text,
message_thread_id=thread_id,
disable_notification=payload.disable_notification,
)
else:
logger.error("Topic payload must contain text or media")
except Exception as e:
logger.error(f"Failed to send system notification to topic {chat_id}/{thread_id}: {e}")
await self._send_topic_config_error(chat_id, thread_id, str(e))
async def delete_notification(self, chat_id: int, message_id: int) -> None:
try:
await self.bot.delete_message(chat_id=chat_id, message_id=message_id)
@@ -253,6 +318,27 @@ class NotificationService(Notifier):
logger.exception(f"Failed to send notification to '{user.telegram_id}': {e}")
raise
async def _send_topic_config_error(
self, chat_id: Optional[int], thread_id: Optional[int], reason: str
) -> None:
"""Fallback: notify owner in personal chat when topic delivery fails."""
target = f"chat_id={chat_id}" + (f", thread_id={thread_id}" if thread_id else "")
error_text = (
f"⚠️ <b>System notification delivery failed</b>\n\n"
f"<b>Target:</b> <code>{target}</code>\n"
f"<b>Reason:</b> <code>{reason[:300]}</code>\n\n"
f"Check the notification route in the dashboard and make sure "
f"the bot is a member of the group with send-message permissions."
)
try:
await self.bot.send_message(
chat_id=self.config.bot.owner_id,
text=error_text,
disable_web_page_preview=True,
)
except Exception as e:
logger.error(f"Failed to deliver topic config error to owner: {e}")
def _get_media_method(self, payload: MessagePayloadDto) -> Optional[Callable[..., Any]]:
if payload.is_photo:
return self.bot.send_photo

View File

@@ -4,7 +4,7 @@ from src.application.common import Interactor
from .commands.access import ChangeAccessMode, TogglePayments, ToggleRegistration
from .commands.currency import UpdateDefaultCurrency
from .commands.notifications import ToggleNotification
from .commands.notifications import ClearSystemNotifyRoute, ToggleNotification, UpdateSystemNotifyRoute
from .commands.referral import (
ToggleReferralSystem,
UpdateReferralAccrualStrategy,
@@ -21,6 +21,7 @@ from .commands.requirements import (
SETTINGS_USE_CASES: Final[tuple[type[Interactor], ...]] = (
ChangeAccessMode,
ClearSystemNotifyRoute,
ToggleConditionRequirement,
ToggleNotification,
TogglePayments,
@@ -34,4 +35,5 @@ SETTINGS_USE_CASES: Final[tuple[type[Interactor], ...]] = (
UpdateReferralRewardType,
UpdateRulesRequirement,
UpdateDefaultCurrency,
UpdateSystemNotifyRoute,
)

View File

@@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import Optional
from loguru import logger
@@ -29,3 +30,53 @@ class ToggleNotification(Interactor[NotificationType, Optional[SettingsDto]]):
logger.info(f"{actor.log} Toggled notification '{notification_type}'")
return updated
@dataclass
class UpdateSystemNotifyRouteDto:
notification_type: NotificationType
chat_id: int
thread_id: Optional[int]
class UpdateSystemNotifyRoute(Interactor[UpdateSystemNotifyRouteDto, Optional[SettingsDto]]):
required_permission = Permission.SETTINGS_NOTIFICATIONS
def __init__(self, uow: UnitOfWork, settings_dao: SettingsDao) -> None:
self.uow = uow
self.settings_dao = settings_dao
async def _execute(
self, actor: UserDto, data: UpdateSystemNotifyRouteDto
) -> Optional[SettingsDto]:
async with self.uow:
settings = await self.settings_dao.get()
settings.notifications.set_route(data.notification_type, data.chat_id, data.thread_id)
updated = await self.settings_dao.update(settings)
await self.uow.commit()
logger.info(
f"{actor.log} Updated notify route for '{data.notification_type}': "
f"chat={data.chat_id}, thread={data.thread_id}"
)
return updated
class ClearSystemNotifyRoute(Interactor[NotificationType, Optional[SettingsDto]]):
required_permission = Permission.SETTINGS_NOTIFICATIONS
def __init__(self, uow: UnitOfWork, settings_dao: SettingsDao) -> None:
self.uow = uow
self.settings_dao = settings_dao
async def _execute(
self, actor: UserDto, notification_type: NotificationType
) -> Optional[SettingsDto]:
async with self.uow:
settings = await self.settings_dao.get()
settings.notifications.clear_route(notification_type)
updated = await self.settings_dao.update(settings)
await self.uow.commit()
logger.info(f"{actor.log} Cleared notify route for '{notification_type}'")
return updated

View File

@@ -0,0 +1,31 @@
"""Add system notification routes to settings.notifications JSONB
Revision ID: 0019
Revises: 0018
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0019"
down_revision: Union[str, None] = "0018"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add 'routes' key to existing notifications JSONB if not already present.
# The column stores {"settings": {...}, "routes": {...}} after this migration.
op.execute("""
UPDATE settings
SET notifications = notifications || '{"routes": {}}'::jsonb
WHERE NOT (notifications ? 'routes')
""")
def downgrade() -> None:
op.execute("""
UPDATE settings
SET notifications = notifications - 'routes'
""")

View File

@@ -1,5 +1,7 @@
from aiogram_dialog import Dialog, Window
from aiogram_dialog.widgets.kbd import Column, Row, Select, Start, SwitchTo
from aiogram_dialog.widgets.kbd import Button, Column, Row, Select, Start, SwitchTo
from aiogram_dialog.widgets.input import TextInput
from aiogram_dialog.widgets.text import Format
from magic_filter import F
from src.core.enums import BannerName, SystemNotificationType, UserNotificationType
@@ -7,8 +9,18 @@ from src.telegram.keyboards import main_menu_button
from src.telegram.states import DashboardRemnashop, RemnashopNotifications
from src.telegram.widgets import Banner, I18nFormat, IgnoreUpdate
from .getters import system_types_getter, user_types_getter
from .handlers import on_system_type_select, on_user_type_select
from .getters import system_route_getter, system_type_getter, system_types_getter, user_types_getter
from .handlers import (
on_chat_id_entered,
on_invalid_int,
on_route_clear,
on_system_route_edit_chat,
on_system_route_edit_thread,
on_system_type_select,
on_system_type_toggle,
on_thread_id_entered,
on_user_type_select,
)
notifications = Window(
Banner(BannerName.DASHBOARD),
@@ -68,20 +80,16 @@ user = Window(
getter=user_types_getter,
)
# Список системных типов — клик открывает подменю типа
system = Window(
Banner(BannerName.DASHBOARD),
I18nFormat("msg-notifications-system"),
Column(
Select(
text=I18nFormat(
"btn-notifications.system-choice",
type=F["item"]["type"],
enabled=F["item"]["enabled"],
),
text=Format("{item[label]}"),
id="type_select",
item_id_getter=lambda item: item["type"],
items="types",
type_factory=SystemNotificationType,
on_click=on_system_type_select,
),
),
@@ -97,8 +105,131 @@ system = Window(
getter=system_types_getter,
)
# Подменю конкретного типа: тоггл + маршрут
system_type = Window(
Banner(BannerName.DASHBOARD),
Format(
"<b>{ntf_type}</b>\n\n"
"Статус: {status}\n"
"Маршрут: {route_info}"
),
Row(
Button(
text=Format("{toggle_btn}"),
id="toggle",
on_click=on_system_type_toggle,
),
SwitchTo(
text=Format("📡 Маршрут"),
id="route",
state=RemnashopNotifications.SYSTEM_ROUTE,
),
),
Row(
SwitchTo(
text=I18nFormat("btn-back.general"),
id="back",
state=RemnashopNotifications.SYSTEM,
),
),
IgnoreUpdate(),
state=RemnashopNotifications.SYSTEM_TYPE,
getter=system_type_getter,
)
# Настройка маршрута
system_route = Window(
Banner(BannerName.DASHBOARD),
Format(
"📡 <b>Маршрут: {ntf_type}</b>\n\n"
"Chat ID: <code>{chat_id}</code>\n"
"Thread ID: <code>{thread_id}</code>\n\n"
"<i>Если маршрут не задан — уведомление идёт в личку админам</i>"
),
Row(
Button(
text=Format("✏️ Chat ID"),
id="edit_chat",
on_click=on_system_route_edit_chat,
),
Button(
text=Format("✏️ Thread ID"),
id="edit_thread",
on_click=on_system_route_edit_thread,
),
),
Row(
Button(
text=Format("🗑 Удалить маршрут"),
id="clear_route",
on_click=on_route_clear,
when=F["has_route"],
),
),
Row(
SwitchTo(
text=I18nFormat("btn-back.general"),
id="back",
state=RemnashopNotifications.SYSTEM_TYPE,
),
),
IgnoreUpdate(),
state=RemnashopNotifications.SYSTEM_ROUTE,
getter=system_route_getter,
)
system_route_chat_id = Window(
Banner(BannerName.DASHBOARD),
Format(
"Введи <b>Chat ID</b> группы\n"
"(отрицательное число, например <code>-1001234567890</code>):"
),
TextInput(
id="chat_id_input",
type_factory=int,
on_success=on_chat_id_entered,
on_error=on_invalid_int,
),
Row(
SwitchTo(
text=I18nFormat("btn-back.general"),
id="back",
state=RemnashopNotifications.SYSTEM_ROUTE,
),
),
state=RemnashopNotifications.SYSTEM_ROUTE_CHAT_ID,
getter=system_route_getter,
)
system_route_thread_id = Window(
Banner(BannerName.DASHBOARD),
Format(
"Введи <b>Thread ID</b> топика\n"
"(число, введи <code>0</code> чтобы сбросить):"
),
TextInput(
id="thread_id_input",
type_factory=int,
on_success=on_thread_id_entered,
on_error=on_invalid_int,
),
Row(
SwitchTo(
text=I18nFormat("btn-back.general"),
id="back",
state=RemnashopNotifications.SYSTEM_ROUTE,
),
),
state=RemnashopNotifications.SYSTEM_ROUTE_THREAD_ID,
getter=system_route_getter,
)
router = Dialog(
notifications,
user,
system,
system_type,
system_route,
system_route_chat_id,
system_route_thread_id,
)

View File

@@ -5,6 +5,7 @@ from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from src.application.common.dao import SettingsDao
from src.application.common import TranslatorRunner
from src.core.enums import SystemNotificationType
@@ -31,17 +32,82 @@ async def user_types_getter(
async def system_types_getter(
dialog_manager: DialogManager,
settings_dao: FromDishka[SettingsDao],
i18n: FromDishka[TranslatorRunner],
**kwargs: Any,
) -> dict[str, Any]:
settings = await settings_dao.get()
types = [
{
types = []
for field, value in settings.notifications.system:
if field == SystemNotificationType.SYSTEM:
continue
has_route = settings.notifications.get_route(field) is not None
label = i18n.get("btn-notifications.system-choice", type=field.upper(), enabled=value)
if has_route:
label = label + " 📡"
types.append({
"type": field.upper(),
"enabled": value,
}
for field, value in settings.notifications.system
if field != SystemNotificationType.SYSTEM
]
"has_route": has_route,
"label": label,
})
return {"types": types}
@inject
async def system_type_getter(
dialog_manager: DialogManager,
settings_dao: FromDishka[SettingsDao],
i18n: FromDishka[TranslatorRunner],
**kwargs: Any,
) -> dict[str, Any]:
settings = await settings_dao.get()
ntf_type = dialog_manager.dialog_data.get("route_ntf_type", "")
enabled = settings.notifications.is_enabled(ntf_type)
route = settings.notifications.get_route(ntf_type)
ntf_label = i18n.get(
"btn-notifications.system-choice",
type=ntf_type.upper(),
enabled=enabled,
)
route_info = "не задан (личка админам)"
if route:
route_info = f"chat={route.chat_id}"
if route.thread_id:
route_info += f", thread={route.thread_id}"
return {
"ntf_type": ntf_label,
"status": "🟢 Включено" if enabled else "🔴 Выключено",
"toggle_btn": "🔴 Выключить" if enabled else "🟢 Включить",
"route_info": route_info,
}
@inject
async def system_route_getter(
dialog_manager: DialogManager,
settings_dao: FromDishka[SettingsDao],
i18n: FromDishka[TranslatorRunner],
**kwargs: Any,
) -> dict[str, Any]:
settings = await settings_dao.get()
ntf_type = dialog_manager.dialog_data.get("route_ntf_type", "")
route = settings.notifications.get_route(ntf_type)
enabled = settings.notifications.is_enabled(ntf_type)
ntf_label = i18n.get(
"btn-notifications.system-choice",
type=ntf_type.upper(),
enabled=enabled,
)
return {
"ntf_type": ntf_label,
"has_route": route is not None,
"chat_id": route.chat_id if route else "",
"thread_id": route.thread_id if (route and route.thread_id) else "",
}

View File

@@ -1,13 +1,20 @@
from aiogram.types import CallbackQuery
from aiogram.types import CallbackQuery, Message
from aiogram_dialog import DialogManager
from aiogram_dialog.widgets.kbd import Select
from aiogram_dialog.widgets.kbd import Button, Select
from aiogram_dialog.widgets.input import ManagedTextInput
from dishka import FromDishka
from dishka.integrations.aiogram_dialog import inject
from src.application.dto import UserDto
from src.application.use_cases.settings.commands.notifications import ToggleNotification
from src.application.use_cases.settings.commands.notifications import (
ClearSystemNotifyRoute,
ToggleNotification,
UpdateSystemNotifyRoute,
UpdateSystemNotifyRouteDto,
)
from src.core.constants import USER_KEY
from src.core.enums import SystemNotificationType, UserNotificationType
from src.telegram.states import RemnashopNotifications
@inject
@@ -22,13 +29,126 @@ async def on_user_type_select(
await toggle_notification(user, selected_type)
@inject
async def on_system_type_select(
callback: CallbackQuery,
widget: Select,
dialog_manager: DialogManager,
selected_type: SystemNotificationType,
selected_type: str,
) -> None:
# strip route indicator suffix if present
clean_type = selected_type.replace(" 📡", "").strip()
dialog_manager.dialog_data["route_ntf_type"] = clean_type
await dialog_manager.switch_to(RemnashopNotifications.SYSTEM_TYPE)
@inject
async def on_system_type_toggle(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
toggle_notification: FromDishka[ToggleNotification],
) -> None:
user: UserDto = dialog_manager.middleware_data[USER_KEY]
await toggle_notification(user, selected_type)
ntf_type = dialog_manager.dialog_data.get("route_ntf_type")
if ntf_type:
await toggle_notification(user, ntf_type)
async def on_system_route_edit_chat(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
) -> None:
await dialog_manager.switch_to(RemnashopNotifications.SYSTEM_ROUTE_CHAT_ID)
async def on_system_route_edit_thread(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
) -> None:
await dialog_manager.switch_to(RemnashopNotifications.SYSTEM_ROUTE_THREAD_ID)
@inject
async def on_route_clear(
callback: CallbackQuery,
widget: Button,
dialog_manager: DialogManager,
clear_route: FromDishka[ClearSystemNotifyRoute],
) -> None:
user: UserDto = dialog_manager.middleware_data[USER_KEY]
ntf_type = dialog_manager.dialog_data.get("route_ntf_type")
if ntf_type:
await clear_route(user, ntf_type)
await dialog_manager.switch_to(RemnashopNotifications.SYSTEM_ROUTE)
@inject
async def on_chat_id_entered(
message: Message,
widget: ManagedTextInput,
dialog_manager: DialogManager,
value: int,
update_route: FromDishka[UpdateSystemNotifyRoute],
) -> None:
user: UserDto = dialog_manager.middleware_data[USER_KEY]
ntf_type = dialog_manager.dialog_data.get("route_ntf_type")
if not ntf_type:
return
settings_dao = await dialog_manager.middleware_data["dishka_container"].get(
__import__("src.application.common.dao", fromlist=["SettingsDao"]).SettingsDao
)
settings = await settings_dao.get()
current_route = settings.notifications.get_route(ntf_type)
thread_id = current_route.thread_id if current_route else None
await update_route(user, UpdateSystemNotifyRouteDto(
notification_type=ntf_type,
chat_id=value,
thread_id=thread_id,
))
await message.delete()
await dialog_manager.switch_to(RemnashopNotifications.SYSTEM_ROUTE)
@inject
async def on_thread_id_entered(
message: Message,
widget: ManagedTextInput,
dialog_manager: DialogManager,
value: int,
update_route: FromDishka[UpdateSystemNotifyRoute],
) -> None:
user: UserDto = dialog_manager.middleware_data[USER_KEY]
ntf_type = dialog_manager.dialog_data.get("route_ntf_type")
if not ntf_type:
return
settings_dao = await dialog_manager.middleware_data["dishka_container"].get(
__import__("src.application.common.dao", fromlist=["SettingsDao"]).SettingsDao
)
settings = await settings_dao.get()
current_route = settings.notifications.get_route(ntf_type)
if not current_route:
await message.answer("⚠️ Сначала укажи Chat ID")
return
await update_route(user, UpdateSystemNotifyRouteDto(
notification_type=ntf_type,
chat_id=current_route.chat_id,
thread_id=value if value != 0 else None,
))
await message.delete()
await dialog_manager.switch_to(RemnashopNotifications.SYSTEM_ROUTE)
async def on_invalid_int(
message: Message,
widget: ManagedTextInput,
dialog_manager: DialogManager,
error: ValueError,
) -> None:
await message.answer("⚠️ Введи целое число")

View File

@@ -134,6 +134,10 @@ class RemnashopNotifications(StatesGroup):
MAIN = State()
USER = State()
SYSTEM = State()
SYSTEM_TYPE = State()
SYSTEM_ROUTE = State()
SYSTEM_ROUTE_CHAT_ID = State()
SYSTEM_ROUTE_THREAD_ID = State()
class RemnashopPlans(StatesGroup):