feat: /notify command for runtime notification filter control (#161)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
64
src/main.py
64
src/main.py
@@ -1235,7 +1235,11 @@ async def run(settings: Settings) -> None:
|
|||||||
"/review - Recent scorecards\n"
|
"/review - Recent scorecards\n"
|
||||||
"/dashboard - Dashboard URL/status\n"
|
"/dashboard - Dashboard URL/status\n"
|
||||||
"/stop - Pause trading\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)
|
await telegram.send_message(message)
|
||||||
|
|
||||||
@@ -1488,6 +1492,63 @@ async def run(settings: Settings) -> None:
|
|||||||
"<b>⚠️ Error</b>\n\nFailed to retrieve reviews."
|
"<b>⚠️ Error</b>\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 = ["<b>🔔 알림 필터 현재 상태</b>\n"]
|
||||||
|
for key, enabled in status.items():
|
||||||
|
icon = "✅" if enabled else "❌"
|
||||||
|
lines.append(f"{icon} <code>{key}</code>")
|
||||||
|
lines.append("\n<i>예) /notify scenario off</i>")
|
||||||
|
lines.append("<i>예) /notify all off</i>")
|
||||||
|
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 = ["<b>🔔 알림 필터 현재 상태</b>\n"]
|
||||||
|
for k, enabled in status.items():
|
||||||
|
icon = "✅" if enabled else "❌"
|
||||||
|
lines.append(f"{icon} <code>{k}</code>")
|
||||||
|
await telegram.send_message("\n".join(lines))
|
||||||
|
elif key in status:
|
||||||
|
icon = "✅" if status[key] else "❌"
|
||||||
|
await telegram.send_message(
|
||||||
|
f"<b>🔔 {key}</b>: {icon} {'켜짐' if status[key] else '꺼짐'}\n"
|
||||||
|
f"<i>/notify {key} on 또는 /notify {key} off</i>"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
valid = ", ".join(list(status.keys()) + ["all"])
|
||||||
|
await telegram.send_message(
|
||||||
|
f"❌ 알 수 없는 키: <code>{key}</code>\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"<code>{key}</code> 알림"
|
||||||
|
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"❌ 알 수 없는 키: <code>{key}</code>\n"
|
||||||
|
f"유효한 키: {valid}"
|
||||||
|
)
|
||||||
|
|
||||||
async def handle_dashboard() -> None:
|
async def handle_dashboard() -> None:
|
||||||
"""Handle /dashboard command - show dashboard URL if enabled."""
|
"""Handle /dashboard command - show dashboard URL if enabled."""
|
||||||
if not settings.DASHBOARD_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("scenarios", handle_scenarios)
|
||||||
command_handler.register_command("review", handle_review)
|
command_handler.register_command("review", handle_review)
|
||||||
command_handler.register_command("dashboard", handle_dashboard)
|
command_handler.register_command("dashboard", handle_dashboard)
|
||||||
|
command_handler.register_command_with_args("notify", handle_notify)
|
||||||
|
|
||||||
# Initialize volatility hunter
|
# Initialize volatility hunter
|
||||||
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, fields
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
@@ -65,6 +66,17 @@ class NotificationFilter:
|
|||||||
circuit_breaker is intentionally omitted — it is always sent regardless.
|
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
|
trades: bool = True
|
||||||
market_open_close: bool = True
|
market_open_close: bool = True
|
||||||
fat_finger: bool = True
|
fat_finger: bool = True
|
||||||
@@ -73,6 +85,18 @@ class NotificationFilter:
|
|||||||
scenario_match: bool = True
|
scenario_match: bool = True
|
||||||
errors: 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
|
@dataclass
|
||||||
class NotificationMessage:
|
class NotificationMessage:
|
||||||
@@ -137,6 +161,26 @@ class TelegramClient:
|
|||||||
if self._session is not None and not self._session.closed:
|
if self._session is not None and not self._session.closed:
|
||||||
await self._session.close()
|
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:
|
async def send_message(self, text: str, parse_mode: str = "HTML") -> bool:
|
||||||
"""
|
"""
|
||||||
Send a generic text message to Telegram.
|
Send a generic text message to Telegram.
|
||||||
@@ -468,6 +512,7 @@ class TelegramCommandHandler:
|
|||||||
self._client = client
|
self._client = client
|
||||||
self._polling_interval = polling_interval
|
self._polling_interval = polling_interval
|
||||||
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
|
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
|
||||||
|
self._commands_with_args: dict[str, Callable[[list[str]], Awaitable[None]]] = {}
|
||||||
self._last_update_id = 0
|
self._last_update_id = 0
|
||||||
self._polling_task: asyncio.Task[None] | None = None
|
self._polling_task: asyncio.Task[None] | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
@@ -476,7 +521,7 @@ class TelegramCommandHandler:
|
|||||||
self, command: str, handler: Callable[[], Awaitable[None]]
|
self, command: str, handler: Callable[[], Awaitable[None]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Register a command handler.
|
Register a command handler (no arguments).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
command: Command name (without leading slash, e.g., "start")
|
command: Command name (without leading slash, e.g., "start")
|
||||||
@@ -485,6 +530,19 @@ class TelegramCommandHandler:
|
|||||||
self._commands[command] = handler
|
self._commands[command] = handler
|
||||||
logger.debug("Registered command handler: /%s", command)
|
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:
|
async def start_polling(self) -> None:
|
||||||
"""Start long polling for commands."""
|
"""Start long polling for commands."""
|
||||||
if self._running:
|
if self._running:
|
||||||
@@ -605,11 +663,14 @@ class TelegramCommandHandler:
|
|||||||
# Remove @botname suffix if present (for group chats)
|
# Remove @botname suffix if present (for group chats)
|
||||||
command_name = command_parts[0].split("@")[0]
|
command_name = command_parts[0].split("@")[0]
|
||||||
|
|
||||||
# Execute handler
|
# Execute handler (args-aware handlers take priority)
|
||||||
handler = self._commands.get(command_name)
|
args_handler = self._commands_with_args.get(command_name)
|
||||||
if handler:
|
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)
|
logger.info("Executing command: /%s", command_name)
|
||||||
await handler()
|
await self._commands[command_name]()
|
||||||
else:
|
else:
|
||||||
logger.debug("Unknown command: /%s", command_name)
|
logger.debug("Unknown command: /%s", command_name)
|
||||||
await self._client.send_message(
|
await self._client.send_message(
|
||||||
|
|||||||
@@ -605,3 +605,63 @@ class TestNotificationFilter:
|
|||||||
await client.notify_system_start("paper", ["KR"])
|
await client.notify_system_start("paper", ["KR"])
|
||||||
await client.notify_system_shutdown("Normal shutdown")
|
await client.notify_system_shutdown("Normal shutdown")
|
||||||
mock_post.assert_not_called()
|
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
|
||||||
|
|||||||
@@ -875,3 +875,91 @@ class TestGetUpdates:
|
|||||||
updates = await handler._get_updates()
|
updates = await handler._get_updates()
|
||||||
|
|
||||||
assert 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 == [[]]
|
||||||
|
|||||||
Reference in New Issue
Block a user