From 4a59d7e66d238c53f51d73a421eba8c633d774b6 Mon Sep 17 00:00:00 2001 From: agentson Date: Fri, 20 Feb 2026 02:33:03 +0900 Subject: [PATCH] feat: /notify command for runtime notification filter control (#161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /notify Telegram command for adjusting notification filters at runtime without restarting the service: /notify → show current filter state /notify scenario off → disable scenario match alerts /notify market off → disable market open/close alerts /notify all off → disable all (circuit_breaker always on) /notify trades on → re-enable trade execution alerts Changes: - NotificationFilter: add KEYS class var, set_flag(), as_dict() - TelegramClient: add set_notification(), filter_status() - TelegramCommandHandler: add register_command_with_args() + args dispatch - main.py: handle_notify() handler + register /notify command + /help update - Tests: 12 new tests (set_flag, set_notification, register_command_with_args) Co-Authored-By: Claude Sonnet 4.6 --- src/main.py | 64 +++++++++++++++++++- src/notifications/telegram_client.py | 73 +++++++++++++++++++++-- tests/test_telegram.py | 60 +++++++++++++++++++ tests/test_telegram_commands.py | 88 ++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+), 7 deletions(-) diff --git a/src/main.py b/src/main.py index 8e2a805..ca7c11e 100644 --- a/src/main.py +++ b/src/main.py @@ -1235,7 +1235,11 @@ async def run(settings: Settings) -> None: "/review - Recent scorecards\n" "/dashboard - Dashboard URL/status\n" "/stop - Pause trading\n" - "/resume - Resume trading" + "/resume - Resume trading\n" + "/notify - Show notification filter status\n" + "/notify [key] [on|off] - Toggle notification type\n" + " Keys: trades, market, scenario, playbook,\n" + " system, fatfinger, errors, all" ) await telegram.send_message(message) @@ -1488,6 +1492,63 @@ async def run(settings: Settings) -> None: "⚠️ Error\n\nFailed to retrieve reviews." ) + async def handle_notify(args: list[str]) -> None: + """Handle /notify [key] [on|off] — query or change notification filters.""" + status = telegram.filter_status() + + # /notify — show current state + if not args: + lines = ["🔔 알림 필터 현재 상태\n"] + for key, enabled in status.items(): + icon = "✅" if enabled else "❌" + lines.append(f"{icon} {key}") + lines.append("\n예) /notify scenario off") + lines.append("예) /notify all off") + await telegram.send_message("\n".join(lines)) + return + + # /notify [key] — missing on/off + if len(args) == 1: + key = args[0].lower() + if key == "all": + lines = ["🔔 알림 필터 현재 상태\n"] + for k, enabled in status.items(): + icon = "✅" if enabled else "❌" + lines.append(f"{icon} {k}") + await telegram.send_message("\n".join(lines)) + elif key in status: + icon = "✅" if status[key] else "❌" + await telegram.send_message( + f"🔔 {key}: {icon} {'켜짐' if status[key] else '꺼짐'}\n" + f"/notify {key} on 또는 /notify {key} off" + ) + else: + valid = ", ".join(list(status.keys()) + ["all"]) + await telegram.send_message( + f"❌ 알 수 없는 키: {key}\n" + f"유효한 키: {valid}" + ) + return + + # /notify [key] [on|off] + key, toggle = args[0].lower(), args[1].lower() + if toggle not in ("on", "off"): + await telegram.send_message("❌ on 또는 off 를 입력해 주세요.") + return + value = toggle == "on" + if telegram.set_notification(key, value): + icon = "✅" if value else "❌" + label = f"전체 알림" if key == "all" else f"{key} 알림" + state = "켜짐" if value else "꺼짐" + await telegram.send_message(f"{icon} {label} → {state}") + logger.info("Notification filter changed via Telegram: %s=%s", key, value) + else: + valid = ", ".join(list(telegram.filter_status().keys()) + ["all"]) + await telegram.send_message( + f"❌ 알 수 없는 키: {key}\n" + f"유효한 키: {valid}" + ) + async def handle_dashboard() -> None: """Handle /dashboard command - show dashboard URL if enabled.""" if not settings.DASHBOARD_ENABLED: @@ -1511,6 +1572,7 @@ async def run(settings: Settings) -> None: command_handler.register_command("scenarios", handle_scenarios) command_handler.register_command("review", handle_review) command_handler.register_command("dashboard", handle_dashboard) + command_handler.register_command_with_args("notify", handle_notify) # Initialize volatility hunter volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0) diff --git a/src/notifications/telegram_client.py b/src/notifications/telegram_client.py index bbfff00..ee61bce 100644 --- a/src/notifications/telegram_client.py +++ b/src/notifications/telegram_client.py @@ -4,8 +4,9 @@ import asyncio import logging import time from collections.abc import Awaitable, Callable -from dataclasses import dataclass +from dataclasses import dataclass, fields from enum import Enum +from typing import ClassVar import aiohttp @@ -65,6 +66,17 @@ class NotificationFilter: circuit_breaker is intentionally omitted — it is always sent regardless. """ + # Maps user-facing command keys to dataclass field names + KEYS: ClassVar[dict[str, str]] = { + "trades": "trades", + "market": "market_open_close", + "fatfinger": "fat_finger", + "system": "system_events", + "playbook": "playbook", + "scenario": "scenario_match", + "errors": "errors", + } + trades: bool = True market_open_close: bool = True fat_finger: bool = True @@ -73,6 +85,18 @@ class NotificationFilter: scenario_match: bool = True errors: bool = True + def set_flag(self, key: str, value: bool) -> bool: + """Set a filter flag by user-facing key. Returns False if key is unknown.""" + field = self.KEYS.get(key.lower()) + if field is None: + return False + setattr(self, field, value) + return True + + def as_dict(self) -> dict[str, bool]: + """Return {user_key: current_value} for display.""" + return {k: getattr(self, field) for k, field in self.KEYS.items()} + @dataclass class NotificationMessage: @@ -137,6 +161,26 @@ class TelegramClient: if self._session is not None and not self._session.closed: await self._session.close() + def set_notification(self, key: str, value: bool) -> bool: + """Toggle a notification type by user-facing key at runtime. + + Args: + key: User-facing key (e.g. "scenario", "market", "all") + value: True to enable, False to disable + + Returns: + True if key was valid, False if unknown. + """ + if key == "all": + for k in NotificationFilter.KEYS: + self._filter.set_flag(k, value) + return True + return self._filter.set_flag(key, value) + + def filter_status(self) -> dict[str, bool]: + """Return current per-type filter state keyed by user-facing names.""" + return self._filter.as_dict() + async def send_message(self, text: str, parse_mode: str = "HTML") -> bool: """ Send a generic text message to Telegram. @@ -468,6 +512,7 @@ class TelegramCommandHandler: self._client = client self._polling_interval = polling_interval self._commands: dict[str, Callable[[], Awaitable[None]]] = {} + self._commands_with_args: dict[str, Callable[[list[str]], Awaitable[None]]] = {} self._last_update_id = 0 self._polling_task: asyncio.Task[None] | None = None self._running = False @@ -476,7 +521,7 @@ class TelegramCommandHandler: self, command: str, handler: Callable[[], Awaitable[None]] ) -> None: """ - Register a command handler. + Register a command handler (no arguments). Args: command: Command name (without leading slash, e.g., "start") @@ -485,6 +530,19 @@ class TelegramCommandHandler: self._commands[command] = handler logger.debug("Registered command handler: /%s", command) + def register_command_with_args( + self, command: str, handler: Callable[[list[str]], Awaitable[None]] + ) -> None: + """ + Register a command handler that receives trailing arguments. + + Args: + command: Command name (without leading slash, e.g., "notify") + handler: Async function receiving list of argument tokens + """ + self._commands_with_args[command] = handler + logger.debug("Registered command handler (with args): /%s", command) + async def start_polling(self) -> None: """Start long polling for commands.""" if self._running: @@ -605,11 +663,14 @@ class TelegramCommandHandler: # Remove @botname suffix if present (for group chats) command_name = command_parts[0].split("@")[0] - # Execute handler - handler = self._commands.get(command_name) - if handler: + # Execute handler (args-aware handlers take priority) + args_handler = self._commands_with_args.get(command_name) + if args_handler: + logger.info("Executing command: /%s %s", command_name, command_parts[1:]) + await args_handler(command_parts[1:]) + elif command_name in self._commands: logger.info("Executing command: /%s", command_name) - await handler() + await self._commands[command_name]() else: logger.debug("Unknown command: /%s", command_name) await self._client.send_message( diff --git a/tests/test_telegram.py b/tests/test_telegram.py index 46ef8cb..606b4e7 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -605,3 +605,63 @@ class TestNotificationFilter: await client.notify_system_start("paper", ["KR"]) await client.notify_system_shutdown("Normal shutdown") mock_post.assert_not_called() + + def test_set_flag_valid_key(self) -> None: + """set_flag returns True and updates field for a known key.""" + nf = NotificationFilter() + assert nf.set_flag("scenario", False) is True + assert nf.scenario_match is False + + def test_set_flag_invalid_key(self) -> None: + """set_flag returns False for an unknown key.""" + nf = NotificationFilter() + assert nf.set_flag("unknown_key", False) is False + + def test_as_dict_keys_match_KEYS(self) -> None: + """as_dict() returns every key defined in KEYS.""" + nf = NotificationFilter() + d = nf.as_dict() + assert set(d.keys()) == set(NotificationFilter.KEYS.keys()) + + def test_set_notification_valid_key(self) -> None: + """TelegramClient.set_notification toggles filter at runtime.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + assert client._filter.scenario_match is True + assert client.set_notification("scenario", False) is True + assert client._filter.scenario_match is False + + def test_set_notification_all_off(self) -> None: + """set_notification('all', False) disables every filter flag.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + assert client.set_notification("all", False) is True + for v in client.filter_status().values(): + assert v is False + + def test_set_notification_all_on(self) -> None: + """set_notification('all', True) enables every filter flag.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True, + notification_filter=NotificationFilter( + trades=False, market_open_close=False, scenario_match=False, + fat_finger=False, system_events=False, playbook=False, errors=False, + ), + ) + assert client.set_notification("all", True) is True + for v in client.filter_status().values(): + assert v is True + + def test_set_notification_unknown_key(self) -> None: + """set_notification returns False for an unknown key.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + assert client.set_notification("unknown", False) is False + + def test_filter_status_reflects_current_state(self) -> None: + """filter_status() matches the current NotificationFilter state.""" + nf = NotificationFilter(trades=False, scenario_match=False) + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True, notification_filter=nf + ) + status = client.filter_status() + assert status["trades"] is False + assert status["scenario"] is False + assert status["market"] is True diff --git a/tests/test_telegram_commands.py b/tests/test_telegram_commands.py index c0f0b98..bf9b437 100644 --- a/tests/test_telegram_commands.py +++ b/tests/test_telegram_commands.py @@ -875,3 +875,91 @@ class TestGetUpdates: updates = await handler._get_updates() assert updates == [] + + +class TestCommandWithArgs: + """Test register_command_with_args and argument dispatch.""" + + def test_register_command_with_args_stored(self) -> None: + """register_command_with_args stores handler in _commands_with_args.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + async def my_handler(args: list[str]) -> None: + pass + + handler.register_command_with_args("notify", my_handler) + assert "notify" in handler._commands_with_args + assert handler._commands_with_args["notify"] is my_handler + + @pytest.mark.asyncio + async def test_args_handler_receives_arguments(self) -> None: + """Args handler is called with the trailing tokens.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + received: list[list[str]] = [] + + async def capture(args: list[str]) -> None: + received.append(args) + + handler.register_command_with_args("notify", capture) + + update = { + "message": { + "chat": {"id": "456"}, + "text": "/notify scenario off", + } + } + await handler._handle_update(update) + assert received == [["scenario", "off"]] + + @pytest.mark.asyncio + async def test_args_handler_takes_priority_over_no_args_handler(self) -> None: + """When both handlers exist for same command, args handler wins.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + no_args_called = [] + args_called = [] + + async def no_args_handler() -> None: + no_args_called.append(True) + + async def args_handler(args: list[str]) -> None: + args_called.append(args) + + handler.register_command("notify", no_args_handler) + handler.register_command_with_args("notify", args_handler) + + update = { + "message": { + "chat": {"id": "456"}, + "text": "/notify all off", + } + } + await handler._handle_update(update) + assert args_called == [["all", "off"]] + assert no_args_called == [] + + @pytest.mark.asyncio + async def test_args_handler_with_no_trailing_args(self) -> None: + """/notify with no args still dispatches to args handler with empty list.""" + client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True) + handler = TelegramCommandHandler(client) + + received: list[list[str]] = [] + + async def capture(args: list[str]) -> None: + received.append(args) + + handler.register_command_with_args("notify", capture) + + update = { + "message": { + "chat": {"id": "456"}, + "text": "/notify", + } + } + await handler._handle_update(update) + assert received == [[]]