Compare commits

..

5 Commits

Author SHA1 Message Date
agentson
48a99962e3 feat: implement basic commands /start and /help (issue #63)
Some checks failed
CI / test (pull_request) Has been cancelled
Integrate TelegramCommandHandler into main.py and implement
welcome and help commands.

Changes:
- Import TelegramCommandHandler in main.py
- Initialize command handler and register /start and /help
- Start/stop command handler with proper lifecycle management
- Add tests for command content validation

Features:
- /start: Welcome message with bot introduction
- /help: Complete command reference
- Handlers respond with HTML-formatted messages
- Clean startup/shutdown integration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 13:55:52 +09:00
ee66ecc305 Merge pull request 'feat: implement TelegramCommandHandler core structure (issue #61)' (#62) from feature/issue-61-command-handler into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #62
2026-02-05 13:51:18 +09:00
agentson
065c9daaad feat: implement TelegramCommandHandler core structure (issue #61)
Some checks failed
CI / test (pull_request) Has been cancelled
Add TelegramCommandHandler class with long polling, command routing,
and security features.

Changes:
- Add TelegramCommandHandler class to telegram_client.py
- Implement long polling with getUpdates API
- Add command registration and routing mechanism
- Implement chat ID verification for security
- Add comprehensive tests (16 tests)
- Coverage: 85% for telegram_client.py

Features:
- start_polling() / stop_polling() lifecycle management
- register_command() for handler registration
- Chat ID verification to prevent unauthorized access
- Error isolation (command failures don't crash system)
- Graceful handling of API errors and timeouts

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 13:47:27 +09:00
c76b9d5c15 Merge pull request 'feat: add generic send_message method to TelegramClient (issue #59)' (#60) from feature/issue-59-send-message into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #60
2026-02-05 13:40:06 +09:00
8e715c55cd Merge pull request 'feat: 일일 거래 모드 + 요구사항 문서화 체계 (issue #57)' (#58) from feature/issue-57-daily-trading-mode into main
Some checks failed
CI / test (push) Has been cancelled
Reviewed-on: #58
2026-02-05 09:49:26 +09:00
3 changed files with 627 additions and 1 deletions

View File

@@ -29,7 +29,7 @@ from src.db import init_db, log_trade
from src.logging.decision_logger import DecisionLogger
from src.logging_config import setup_logging
from src.markets.schedule import MarketInfo, get_next_market_open, get_open_markets
from src.notifications.telegram_client import TelegramClient
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler
logger = logging.getLogger(__name__)
@@ -575,6 +575,40 @@ async def run(settings: Settings) -> None:
enabled=settings.TELEGRAM_ENABLED,
)
# Initialize Telegram command handler
command_handler = TelegramCommandHandler(telegram)
# Register basic commands
async def handle_start() -> None:
"""Handle /start command."""
message = (
"<b>🤖 The Ouroboros Trading Bot</b>\n\n"
"AI-powered global stock trading agent with real-time notifications.\n\n"
"<b>Available commands:</b>\n"
"/help - Show this help message\n"
"/status - Current trading status\n"
"/positions - View holdings\n"
"/stop - Pause trading\n"
"/resume - Resume trading"
)
await telegram.send_message(message)
async def handle_help() -> None:
"""Handle /help command."""
message = (
"<b>📖 Available Commands</b>\n\n"
"/start - Welcome message\n"
"/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\n"
"/stop - Pause trading\n"
"/resume - Resume trading"
)
await telegram.send_message(message)
command_handler.register_command("start", handle_start)
command_handler.register_command("help", handle_help)
# Initialize volatility hunter
volatility_analyzer = VolatilityAnalyzer(min_volume_surge=2.0, min_price_change=1.0)
market_scanner = MarketScanner(
@@ -621,6 +655,12 @@ async def run(settings: Settings) -> None:
except Exception as exc:
logger.warning("System startup notification failed: %s", exc)
# Start command handler
try:
await command_handler.start_polling()
except Exception as exc:
logger.warning("Failed to start command handler: %s", exc)
try:
# Branch based on trading mode
if settings.TRADE_MODE == "daily":
@@ -831,6 +871,7 @@ async def run(settings: Settings) -> None:
pass # Normal — timeout means it's time for next cycle
finally:
# Clean up resources
await command_handler.stop_polling()
await broker.close()
await telegram.close()
db_conn.close()

View File

@@ -3,6 +3,7 @@
import asyncio
import logging
import time
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from enum import Enum
@@ -339,3 +340,171 @@ class TelegramClient:
await self._send_notification(
NotificationMessage(priority=NotificationPriority.HIGH, message=message)
)
class TelegramCommandHandler:
"""Handles incoming Telegram commands via long polling."""
def __init__(
self, client: TelegramClient, polling_interval: float = 1.0
) -> None:
"""
Initialize command handler.
Args:
client: TelegramClient instance for sending responses
polling_interval: Polling interval in seconds
"""
self._client = client
self._polling_interval = polling_interval
self._commands: dict[str, Callable[[], Awaitable[None]]] = {}
self._last_update_id = 0
self._polling_task: asyncio.Task[None] | None = None
self._running = False
def register_command(
self, command: str, handler: Callable[[], Awaitable[None]]
) -> None:
"""
Register a command handler.
Args:
command: Command name (without leading slash, e.g., "start")
handler: Async function to handle the command
"""
self._commands[command] = handler
logger.debug("Registered command handler: /%s", command)
async def start_polling(self) -> None:
"""Start long polling for commands."""
if self._running:
logger.warning("Command handler already running")
return
if not self._client._enabled:
logger.info("Command handler disabled (TelegramClient disabled)")
return
self._running = True
self._polling_task = asyncio.create_task(self._poll_loop())
logger.info("Started Telegram command polling")
async def stop_polling(self) -> None:
"""Stop polling and cancel pending tasks."""
if not self._running:
return
self._running = False
if self._polling_task:
self._polling_task.cancel()
try:
await self._polling_task
except asyncio.CancelledError:
pass
logger.info("Stopped Telegram command polling")
async def _poll_loop(self) -> None:
"""Main polling loop that fetches updates."""
while self._running:
try:
updates = await self._get_updates()
for update in updates:
await self._handle_update(update)
except asyncio.CancelledError:
break
except Exception as exc:
logger.error("Error in polling loop: %s", exc)
await asyncio.sleep(self._polling_interval)
async def _get_updates(self) -> list[dict]:
"""
Fetch updates from Telegram API.
Returns:
List of update objects
"""
try:
url = f"{self._client.API_BASE.format(token=self._client._bot_token)}/getUpdates"
payload = {
"offset": self._last_update_id + 1,
"timeout": int(self._polling_interval),
"allowed_updates": ["message"],
}
session = self._client._get_session()
async with session.post(url, json=payload) as resp:
if resp.status != 200:
error_text = await resp.text()
logger.error(
"getUpdates API error (status=%d): %s", resp.status, error_text
)
return []
data = await resp.json()
if not data.get("ok"):
logger.error("getUpdates returned ok=false: %s", data)
return []
updates = data.get("result", [])
if updates:
self._last_update_id = updates[-1]["update_id"]
return updates
except asyncio.TimeoutError:
logger.debug("getUpdates timeout (normal)")
return []
except aiohttp.ClientError as exc:
logger.error("getUpdates failed: %s", exc)
return []
except Exception as exc:
logger.error("Unexpected error in _get_updates: %s", exc)
return []
async def _handle_update(self, update: dict) -> None:
"""
Parse and handle a single update.
Args:
update: Update object from Telegram API
"""
try:
message = update.get("message")
if not message:
return
# Verify chat_id matches configured chat
chat_id = str(message.get("chat", {}).get("id", ""))
if chat_id != self._client._chat_id:
logger.warning(
"Ignoring command from unauthorized chat_id: %s", chat_id
)
return
# Extract command text
text = message.get("text", "").strip()
if not text.startswith("/"):
return
# Parse command (remove leading slash and extract command name)
command_parts = text[1:].split()
if not command_parts:
return
command_name = command_parts[0]
# Execute handler
handler = self._commands.get(command_name)
if handler:
logger.info("Executing command: /%s", command_name)
await handler()
else:
logger.debug("Unknown command: /%s", command_name)
await self._client.send_message(
f"Unknown command: /{command_name}\nUse /help to see available commands."
)
except Exception as exc:
logger.error("Error handling update: %s", exc)
# Don't crash the polling loop on handler errors

View File

@@ -0,0 +1,416 @@
"""Tests for Telegram command handler."""
from unittest.mock import AsyncMock, patch
import pytest
from src.notifications.telegram_client import TelegramClient, TelegramCommandHandler
class TestCommandHandlerInit:
"""Test command handler initialization."""
def test_init_with_client(self) -> None:
"""Handler initializes with TelegramClient."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
assert handler._client is client
assert handler._polling_interval == 1.0
assert handler._commands == {}
assert handler._running is False
def test_custom_polling_interval(self) -> None:
"""Handler accepts custom polling interval."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client, polling_interval=2.5)
assert handler._polling_interval == 2.5
class TestCommandRegistration:
"""Test command registration."""
@pytest.mark.asyncio
async def test_register_command(self) -> None:
"""Commands can be registered."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def test_handler() -> None:
pass
handler.register_command("test", test_handler)
assert "test" in handler._commands
assert handler._commands["test"] is test_handler
@pytest.mark.asyncio
async def test_register_multiple_commands(self) -> None:
"""Multiple commands can be registered."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def handler1() -> None:
pass
async def handler2() -> None:
pass
handler.register_command("start", handler1)
handler.register_command("help", handler2)
assert len(handler._commands) == 2
assert handler._commands["start"] is handler1
assert handler._commands["help"] is handler2
class TestPollingLifecycle:
"""Test polling start/stop."""
@pytest.mark.asyncio
async def test_start_polling(self) -> None:
"""Polling can be started."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
await handler.start_polling()
assert handler._running is True
assert handler._polling_task is not None
await handler.stop_polling()
@pytest.mark.asyncio
async def test_start_polling_disabled_client(self) -> None:
"""Polling not started when client disabled."""
client = TelegramClient(enabled=False)
handler = TelegramCommandHandler(client)
await handler.start_polling()
assert handler._running is False
assert handler._polling_task is None
@pytest.mark.asyncio
async def test_stop_polling(self) -> None:
"""Polling can be stopped."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
await handler.start_polling()
await handler.stop_polling()
assert handler._running is False
@pytest.mark.asyncio
async def test_double_start_ignored(self) -> None:
"""Starting already running handler is ignored."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
with patch.object(handler, "_poll_loop", new_callable=AsyncMock):
await handler.start_polling()
task1 = handler._polling_task
await handler.start_polling() # Second start
task2 = handler._polling_task
# Should be the same task
assert task1 is task2
await handler.stop_polling()
class TestUpdateHandling:
"""Test update parsing and handling."""
@pytest.mark.asyncio
async def test_handle_valid_command(self) -> None:
"""Valid commands are executed."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("test", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/test",
},
}
await handler._handle_update(update)
assert executed is True
@pytest.mark.asyncio
async def test_handle_unknown_command(self) -> None:
"""Unknown commands send help message."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/unknown",
},
}
await handler._handle_update(update)
# Should send error message
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Unknown command" in payload["text"]
assert "/unknown" in payload["text"]
@pytest.mark.asyncio
async def test_ignore_unauthorized_chat(self) -> None:
"""Commands from unauthorized chats are ignored."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("test", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 999}, # Wrong chat_id
"text": "/test",
},
}
await handler._handle_update(update)
assert executed is False
@pytest.mark.asyncio
async def test_ignore_non_command_text(self) -> None:
"""Non-command text is ignored."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
executed = False
async def test_command() -> None:
nonlocal executed
executed = True
handler.register_command("test", test_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "Hello, not a command",
},
}
await handler._handle_update(update)
assert executed is False
@pytest.mark.asyncio
async def test_handle_update_error_isolation(self) -> None:
"""Errors in handlers don't crash the system."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
async def failing_command() -> None:
raise ValueError("Test error")
handler.register_command("fail", failing_command)
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/fail",
},
}
# Should not raise exception
await handler._handle_update(update)
class TestBasicCommands:
"""Test basic command implementations."""
@pytest.mark.asyncio
async def test_start_command_content(self) -> None:
"""Start command contains welcome message and command list."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_start() -> None:
"""Mock /start handler."""
message = (
"<b>🤖 The Ouroboros Trading Bot</b>\n\n"
"AI-powered global stock trading agent with real-time notifications.\n\n"
"<b>Available commands:</b>\n"
"/help - Show this help message\n"
"/status - Current trading status\n"
"/positions - View holdings\n"
"/stop - Pause trading\n"
"/resume - Resume trading"
)
await client.send_message(message)
handler.register_command("start", mock_start)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/start",
},
}
await handler._handle_update(update)
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Ouroboros Trading Bot" in payload["text"]
assert "/help" in payload["text"]
assert "/status" in payload["text"]
@pytest.mark.asyncio
async def test_help_command_content(self) -> None:
"""Help command lists all available commands."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
async def mock_help() -> None:
"""Mock /help handler."""
message = (
"<b>📖 Available Commands</b>\n\n"
"/start - Welcome message\n"
"/help - Show available commands\n"
"/status - Trading status (mode, markets, P&L)\n"
"/positions - Current holdings\n"
"/stop - Pause trading\n"
"/resume - Resume trading"
)
await client.send_message(message)
handler.register_command("help", mock_help)
with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post:
update = {
"update_id": 1,
"message": {
"chat": {"id": 456},
"text": "/help",
},
}
await handler._handle_update(update)
# Verify message was sent
assert mock_post.call_count == 1
payload = mock_post.call_args.kwargs["json"]
assert "Available Commands" in payload["text"]
assert "/start" in payload["text"]
assert "/help" in payload["text"]
assert "/status" in payload["text"]
assert "/positions" in payload["text"]
assert "/stop" in payload["text"]
assert "/resume" in payload["text"]
class TestGetUpdates:
"""Test getUpdates API interaction."""
@pytest.mark.asyncio
async def test_get_updates_success(self) -> None:
"""getUpdates fetches and parses updates."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={
"ok": True,
"result": [
{"update_id": 1, "message": {"text": "/test"}},
{"update_id": 2, "message": {"text": "/help"}},
],
}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert len(updates) == 2
assert updates[0]["update_id"] == 1
assert updates[1]["update_id"] == 2
assert handler._last_update_id == 2
@pytest.mark.asyncio
async def test_get_updates_api_error(self) -> None:
"""getUpdates handles API errors gracefully."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 400
mock_resp.text = AsyncMock(return_value="Bad Request")
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert updates == []
@pytest.mark.asyncio
async def test_get_updates_empty_result(self) -> None:
"""getUpdates handles empty results."""
client = TelegramClient(bot_token="123:abc", chat_id="456", enabled=True)
handler = TelegramCommandHandler(client)
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"ok": True, "result": []})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
with patch("aiohttp.ClientSession.post", return_value=mock_resp):
updates = await handler._get_updates()
assert updates == []