diff --git a/bot/commands.py b/bot/commands.py
new file mode 100644
index 0000000..93351e4
--- /dev/null
+++ b/bot/commands.py
@@ -0,0 +1,106 @@
+# -*- coding: utf-8 -*-
+"""
+Command handlers for the Telegram bot.
+Handles slash commands like /start, /export_map, /spawn_stats.
+"""
+import logging
+import os
+import json
+from io import BytesIO
+from telegram import Update
+from telegram.ext import ContextTypes
+from . import database, keyboards
+from .utils import admin_only
+from .action_handlers import get_player_status_text
+from data.world_loader import game_world
+
+logger = logging.getLogger(__name__)
+
+
+async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Handle /start command - initialize or show player status."""
+ user = update.effective_user
+ player = await database.get_player(user.id)
+
+ if not player:
+ await database.create_player(user.id, user.first_name)
+ await update.message.reply_html(
+ f"Welcome, {user.mention_html()}! Your story is just beginning."
+ )
+
+ # Get player status and location image
+ player = await database.get_player(user.id)
+ status_text = await get_player_status_text(user.id)
+ location = game_world.get_location(player['location_id'])
+
+ # Send with image if available
+ if location and location.image_path:
+ cached_file_id = await database.get_cached_image(location.image_path)
+ if cached_file_id:
+ await update.message.reply_photo(
+ photo=cached_file_id,
+ caption=status_text,
+ reply_markup=keyboards.main_menu_keyboard(),
+ parse_mode='HTML'
+ )
+ elif os.path.exists(location.image_path):
+ with open(location.image_path, 'rb') as img_file:
+ msg = await update.message.reply_photo(
+ photo=img_file,
+ caption=status_text,
+ reply_markup=keyboards.main_menu_keyboard(),
+ parse_mode='HTML'
+ )
+ if msg.photo:
+ await database.cache_image(location.image_path, msg.photo[-1].file_id)
+ else:
+ await update.message.reply_html(
+ status_text,
+ reply_markup=keyboards.main_menu_keyboard()
+ )
+ else:
+ await update.message.reply_html(
+ status_text,
+ reply_markup=keyboards.main_menu_keyboard()
+ )
+
+
+@admin_only
+async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Export map data as JSON for external visualization."""
+ from data.world_loader import export_map_data
+
+ map_data = export_map_data()
+ json_str = json.dumps(map_data, indent=2)
+
+ # Send as text file
+ file = BytesIO(json_str.encode('utf-8'))
+ file.name = "map_data.json"
+
+ await update.message.reply_document(
+ document=file,
+ filename="map_data.json",
+ caption="πΊοΈ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools."
+ )
+
+
+@admin_only
+async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
+ """Show wandering enemy spawn statistics (debug command)."""
+ from bot.spawn_manager import get_spawn_stats
+
+ stats = await get_spawn_stats()
+
+ text = "π Wandering Enemy Statistics\n\n"
+ text += f"Total Active Enemies: {stats['total_active']}\n\n"
+
+ if stats['by_location']:
+ text += "Enemies by Location:\n"
+ for loc_id, count in stats['by_location'].items():
+ location = game_world.get_location(loc_id)
+ loc_name = location.name if location else loc_id
+ text += f"β’ {loc_name}: {count}\n"
+ else:
+ text += "No wandering enemies currently active."
+
+ await update.message.reply_html(text)
diff --git a/bot/handlers.py b/bot/handlers.py
index d4c7879..1f6a40a 100644
--- a/bot/handlers.py
+++ b/bot/handlers.py
@@ -1,21 +1,24 @@
"""
Main handlers for the Telegram bot.
-This module contains the core message routing and utility functions.
-All specific action handlers are organized in separate modules.
+This module contains the core button callback routing.
+All other functionality is organized in separate modules:
+- action_handlers.py - World interaction handlers
+- inventory_handlers.py - Inventory management
+- combat_handlers.py - Combat actions
+- profile_handlers.py - Character stats
+- corpse_handlers.py - Looting system
+- pickup_handlers.py - Item collection
+- message_utils.py - Message sending/editing utilities
+- commands.py - Slash command handlers
"""
import logging
-import os
-import json
-from telegram import Update, InlineKeyboardMarkup, InputMediaPhoto
+from telegram import Update
from telegram.ext import ContextTypes
-from telegram.error import BadRequest
-from . import database, keyboards
-from .utils import admin_only
-from data.world_loader import game_world
+from . import database
+from .message_utils import send_or_edit_with_image
# Import organized action handlers
from .action_handlers import (
- get_player_status_text,
handle_inspect_area,
handle_attack_wandering,
handle_inspect_interactable,
@@ -55,6 +58,9 @@ from .corpse_handlers import (
handle_scavenge_corpse_item
)
+# Import command handlers (for main.py to register)
+from .commands import start, export_map, spawn_stats
+
logger = logging.getLogger(__name__)
@@ -109,206 +115,6 @@ HANDLER_MAP = {
}
-# ============================================================================
-# UTILITY FUNCTIONS
-# ============================================================================
-
-async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup,
- image_path: str = None, parse_mode='HTML'):
- """
- Send a message with an image (as caption) or edit existing message.
- Uses edit_message_media for smooth transitions when changing images.
- """
- current_message = query.message
- has_photo = bool(current_message.photo)
-
- if image_path:
- # Get or upload image
- cached_file_id = await database.get_cached_image(image_path)
-
- if not cached_file_id and os.path.exists(image_path):
- # Upload new image
- try:
- with open(image_path, 'rb') as img_file:
- temp_msg = await current_message.reply_photo(
- photo=img_file,
- caption=text,
- reply_markup=reply_markup,
- parse_mode=parse_mode
- )
- if temp_msg.photo:
- cached_file_id = temp_msg.photo[-1].file_id
- await database.cache_image(image_path, cached_file_id)
- # Delete old message to keep chat clean
- try:
- await current_message.delete()
- except:
- pass
- return
- except Exception as e:
- logger.error(f"Error uploading image: {e}")
- cached_file_id = None
-
- if cached_file_id:
- # Check if current message has same photo
- if has_photo:
- current_file_id = current_message.photo[-1].file_id
- if current_file_id == cached_file_id:
- # Same image, just edit caption
- try:
- await query.edit_message_caption(
- caption=text,
- reply_markup=reply_markup,
- parse_mode=parse_mode
- )
- return
- except BadRequest as e:
- if "Message is not modified" in str(e):
- return
- else:
- # Different image - use edit_message_media for smooth transition
- try:
- media = InputMediaPhoto(
- media=cached_file_id,
- caption=text,
- parse_mode=parse_mode
- )
- await query.edit_message_media(
- media=media,
- reply_markup=reply_markup
- )
- return
- except Exception as e:
- logger.error(f"Error editing message media: {e}")
-
- # Current message has no photo - need to delete and send new
- if not has_photo:
- try:
- await current_message.delete()
- except:
- pass
-
- try:
- await current_message.reply_photo(
- photo=cached_file_id,
- caption=text,
- reply_markup=reply_markup,
- parse_mode=parse_mode
- )
- except Exception as e:
- logger.error(f"Error sending cached image: {e}")
- else:
- # No image requested
- if has_photo:
- # Current message has photo, need to delete and send text-only
- try:
- await current_message.delete()
- except:
- pass
- await current_message.reply_html(text=text, reply_markup=reply_markup)
- else:
- # Both text-only, just edit
- try:
- await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode)
- except BadRequest as e:
- if "Message is not modified" not in str(e):
- await current_message.reply_html(text=text, reply_markup=reply_markup)
-
-
-# ============================================================================
-# COMMAND HANDLERS
-# ============================================================================
-
-async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Handle /start command - initialize or show player status."""
- user = update.effective_user
- player = await database.get_player(user.id)
-
- if not player:
- await database.create_player(user.id, user.first_name)
- await update.message.reply_html(
- f"Welcome, {user.mention_html()}! Your story is just beginning."
- )
-
- # Get player status and location image
- player = await database.get_player(user.id)
- status_text = await get_player_status_text(user.id)
- location = game_world.get_location(player['location_id'])
-
- # Send with image if available
- if location and location.image_path:
- cached_file_id = await database.get_cached_image(location.image_path)
- if cached_file_id:
- await update.message.reply_photo(
- photo=cached_file_id,
- caption=status_text,
- reply_markup=keyboards.main_menu_keyboard(),
- parse_mode='HTML'
- )
- elif os.path.exists(location.image_path):
- with open(location.image_path, 'rb') as img_file:
- msg = await update.message.reply_photo(
- photo=img_file,
- caption=status_text,
- reply_markup=keyboards.main_menu_keyboard(),
- parse_mode='HTML'
- )
- if msg.photo:
- await database.cache_image(location.image_path, msg.photo[-1].file_id)
- else:
- await update.message.reply_html(
- status_text,
- reply_markup=keyboards.main_menu_keyboard()
- )
- else:
- await update.message.reply_html(
- status_text,
- reply_markup=keyboards.main_menu_keyboard()
- )
-
-
-@admin_only
-async def export_map(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Export map data as JSON for external visualization."""
- from data.world_loader import export_map_data
- from io import BytesIO
-
- map_data = export_map_data()
- json_str = json.dumps(map_data, indent=2)
-
- # Send as text file
- file = BytesIO(json_str.encode('utf-8'))
- file.name = "map_data.json"
-
- await update.message.reply_document(
- document=file,
- filename="map_data.json",
- caption="πΊοΈ Game Map Data\n\nThis JSON file contains all locations, coordinates, and connections.\nYou can use it to visualize the game map in external tools."
- )
-
-
-@admin_only
-async def spawn_stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
- """Show wandering enemy spawn statistics (debug command)."""
- from bot.spawn_manager import get_spawn_stats
-
- stats = await get_spawn_stats()
-
- text = "π Wandering Enemy Statistics\n\n"
- text += f"Total Active Enemies: {stats['total_active']}\n\n"
-
- if stats['by_location']:
- text += "Enemies by Location:\n"
- for loc_id, count in stats['by_location'].items():
- location = game_world.get_location(loc_id)
- loc_name = location.name if location else loc_id
- text += f"β’ {loc_name}: {count}\n"
- else:
- text += "No wandering enemies currently active."
-
- await update.message.reply_html(text)
-
-
# ============================================================================
# BUTTON CALLBACK ROUTER
# ============================================================================
diff --git a/bot/message_utils.py b/bot/message_utils.py
new file mode 100644
index 0000000..dcfa5fe
--- /dev/null
+++ b/bot/message_utils.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+"""
+Message utility functions for sending and editing Telegram messages.
+Handles image caching, smooth transitions, and message editing logic.
+"""
+import logging
+import os
+from telegram import InlineKeyboardMarkup, InputMediaPhoto
+from telegram.error import BadRequest
+from . import database
+
+logger = logging.getLogger(__name__)
+
+
+async def send_or_edit_with_image(query, text: str, reply_markup: InlineKeyboardMarkup,
+ image_path: str = None, parse_mode: str = 'HTML'):
+ """
+ Send a message with an image (as caption) or edit existing message.
+ Uses edit_message_media for smooth transitions when changing images.
+
+ Args:
+ query: The callback query object
+ text: Message text/caption
+ reply_markup: Inline keyboard markup
+ image_path: Optional path to image file
+ parse_mode: Parse mode for text (default 'HTML')
+ """
+ current_message = query.message
+ has_photo = bool(current_message.photo)
+
+ if image_path:
+ # Get or upload image
+ cached_file_id = await database.get_cached_image(image_path)
+
+ if not cached_file_id and os.path.exists(image_path):
+ # Upload new image
+ try:
+ with open(image_path, 'rb') as img_file:
+ temp_msg = await current_message.reply_photo(
+ photo=img_file,
+ caption=text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode
+ )
+ if temp_msg.photo:
+ cached_file_id = temp_msg.photo[-1].file_id
+ await database.cache_image(image_path, cached_file_id)
+ # Delete old message to keep chat clean
+ try:
+ await current_message.delete()
+ except:
+ pass
+ return
+ except Exception as e:
+ logger.error(f"Error uploading image: {e}")
+ cached_file_id = None
+
+ if cached_file_id:
+ # Check if current message has same photo
+ if has_photo:
+ current_file_id = current_message.photo[-1].file_id
+ if current_file_id == cached_file_id:
+ # Same image, just edit caption
+ try:
+ await query.edit_message_caption(
+ caption=text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode
+ )
+ return
+ except BadRequest as e:
+ if "Message is not modified" in str(e):
+ return
+ else:
+ # Different image - use edit_message_media for smooth transition
+ try:
+ media = InputMediaPhoto(
+ media=cached_file_id,
+ caption=text,
+ parse_mode=parse_mode
+ )
+ await query.edit_message_media(
+ media=media,
+ reply_markup=reply_markup
+ )
+ return
+ except Exception as e:
+ logger.error(f"Error editing message media: {e}")
+
+ # Current message has no photo - need to delete and send new
+ if not has_photo:
+ try:
+ await current_message.delete()
+ except:
+ pass
+
+ try:
+ await current_message.reply_photo(
+ photo=cached_file_id,
+ caption=text,
+ reply_markup=reply_markup,
+ parse_mode=parse_mode
+ )
+ except Exception as e:
+ logger.error(f"Error sending cached image: {e}")
+ else:
+ # No image requested
+ if has_photo:
+ # Current message has photo, need to delete and send text-only
+ try:
+ await current_message.delete()
+ except:
+ pass
+ await current_message.reply_html(text=text, reply_markup=reply_markup)
+ else:
+ # Both text-only, just edit
+ try:
+ await query.edit_message_text(text=text, reply_markup=reply_markup, parse_mode=parse_mode)
+ except BadRequest as e:
+ if "Message is not modified" not in str(e):
+ await current_message.reply_html(text=text, reply_markup=reply_markup)
diff --git a/docs/development/MODULE_SEPARATION.md b/docs/development/MODULE_SEPARATION.md
new file mode 100644
index 0000000..ac0ea3e
--- /dev/null
+++ b/docs/development/MODULE_SEPARATION.md
@@ -0,0 +1,223 @@
+# Module Separation - Clean Architecture
+
+**Date:** October 20, 2025
+**Status:** β
Complete
+
+## Overview
+
+Extracted utility functions and command handlers from `handlers.py` into separate, focused modules, achieving true single-responsibility principle.
+
+## Changes
+
+### New Modules Created
+
+#### 1. `bot/message_utils.py` (120 lines)
+**Purpose:** Message sending and editing utilities
+
+**Contains:**
+- `send_or_edit_with_image()` - Smart message/image handling
+ - Image caching and upload
+ - Smooth transitions between images
+ - Text-only message handling
+ - Edit vs send logic
+
+**Responsibilities:**
+- Telegram message manipulation
+- Image file I/O and caching
+- Error handling for message edits
+
+#### 2. `bot/commands.py` (110 lines)
+**Purpose:** Slash command handlers
+
+**Contains:**
+- `start()` - Initialize player and show status
+- `export_map()` - Export map data as JSON (admin only)
+- `spawn_stats()` - Show enemy spawn statistics (admin only)
+
+**Responsibilities:**
+- Command implementation
+- Player initialization
+- Admin commands
+
+### Refactored Module
+
+#### `bot/handlers.py`
+**Before:** 365 lines (routing + utilities + commands)
+**After:** 177 lines (pure routing only)
+**Reduction:** -188 lines (-51%)
+
+**Now Contains Only:**
+- Handler imports
+- `HANDLER_MAP` registry
+- `button_handler()` router
+- Re-exports of commands for main.py
+
+**Removed:**
+- ~~120 lines of utility functions~~
+- ~~110 lines of command handlers~~
+
+## Architecture
+
+### Before
+```
+handlers.py (365 lines)
+βββ Imports (60 lines)
+βββ Handler Registry (50 lines)
+βββ Utility Functions (120 lines) β Mixed concerns
+βββ Command Handlers (110 lines) β Mixed concerns
+βββ Button Router (25 lines)
+```
+
+### After
+```
+handlers.py (177 lines) - Pure routing
+βββ Imports
+βββ Handler Registry
+βββ Button Router
+
+message_utils.py (120 lines) - Message handling
+βββ send_or_edit_with_image()
+
+commands.py (110 lines) - Command handlers
+βββ start()
+βββ export_map()
+βββ spawn_stats()
+```
+
+## Benefits
+
+### 1. **Single Responsibility Principle**
+Each module has one clear purpose:
+- `handlers.py` β Route button callbacks
+- `message_utils.py` β Handle Telegram messages
+- `commands.py` β Implement slash commands
+
+### 2. **Improved Testability**
+Can test each module independently:
+```python
+# Test message utils without routing
+from bot.message_utils import send_or_edit_with_image
+
+# Test commands without button handlers
+from bot.commands import start, export_map
+
+# Test routing without utility logic
+from bot.handlers import button_handler
+```
+
+### 3. **Better Organization**
+Clear separation of concerns:
+- **Routing logic** β handlers.py
+- **Message I/O** β message_utils.py
+- **User commands** β commands.py
+- **Game actions** β action_handlers.py, combat_handlers.py, etc.
+
+### 4. **Easier Maintenance**
+- Find code faster (know which file to open)
+- Modify one concern without affecting others
+- Less merge conflicts (changes in different files)
+
+### 5. **Cleaner Imports**
+```python
+# Before (everything from handlers)
+from bot.handlers import start, button_handler, send_or_edit_with_image
+
+# After (clear module boundaries)
+from bot.handlers import button_handler
+from bot.commands import start
+from bot.message_utils import send_or_edit_with_image
+```
+
+## File Structure
+
+```
+bot/
+βββ __init__.py
+βββ handlers.py (177 lines) - Router only
+βββ message_utils.py (120 lines) - Message utilities
+βββ commands.py (110 lines) - Slash commands
+βββ action_handlers.py (372 lines) - World actions
+βββ inventory_handlers.py(355 lines) - Inventory
+βββ combat_handlers.py (172 lines) - Combat
+βββ profile_handlers.py (147 lines) - Stats
+βββ corpse_handlers.py (234 lines) - Looting
+βββ pickup_handlers.py (135 lines) - Pickups
+βββ utils.py (120 lines) - General utilities
+βββ database.py - Data layer
+βββ keyboards.py - UI layer
+βββ logic.py - Game logic
+βββ combat.py - Combat system
+```
+
+## Code Metrics
+
+| Metric | Before | After | Change |
+|--------|--------|-------|--------|
+| handlers.py size | 365 lines | 177 lines | -188 (-51%) |
+| Modules | 10 | 12 | +2 |
+| Max module size | 372 lines | 372 lines | Unchanged |
+| Avg module size | ~250 lines | ~200 lines | -50 lines |
+| Separation of Concerns | β οΈ Mixed | β
Clean | Improved |
+
+## Backward Compatibility
+
+β
**Fully backward compatible**
+
+The refactoring maintains all existing interfaces:
+```python
+# main.py continues to work unchanged
+from bot import handlers
+
+application.add_handler(CommandHandler("start", handlers.start))
+application.add_handler(CallbackQueryHandler(handlers.button_handler))
+```
+
+`handlers.py` re-exports commands for compatibility:
+```python
+# handlers.py
+from .commands import start, export_map, spawn_stats # Re-export
+
+# Allows: handlers.start (from main.py)
+```
+
+## Testing
+
+All modules tested and working:
+- β
`bot/handlers.py` - Router works correctly
+- β
`bot/message_utils.py` - Image handling works
+- β
`bot/commands.py` - Commands execute properly
+- β
No import errors
+- β
No runtime errors
+- β
All handler calls work identically
+
+## Migration Path
+
+If you want to update imports in the future:
+
+### Option 1: Keep Current (Recommended)
+```python
+# main.py
+from bot import handlers
+handlers.start # Works via re-export
+```
+
+### Option 2: Direct Imports
+```python
+# main.py
+from bot.commands import start, export_map, spawn_stats
+from bot.handlers import button_handler
+```
+
+Both work identically!
+
+## Conclusion
+
+This refactoring achieves:
+- β
**51% reduction** in handlers.py size
+- β
**Clear separation** of concerns
+- β
**Better organization** and discoverability
+- β
**Improved testability** and maintainability
+- β
**Full backward compatibility**
+- β
**No behavior changes**
+
+The codebase is now cleaner, more modular, and follows best practices for Python project structure!