Separate utilities and commands into dedicated modules
Extract functionality from handlers.py into focused modules: New Modules: - bot/message_utils.py (120 lines) - Telegram message handling * send_or_edit_with_image() - Smart message/image transitions * Image caching and upload logic - bot/commands.py (110 lines) - Slash command handlers * start() - Player initialization * export_map() - Admin map export * spawn_stats() - Admin spawn statistics Refactored: - bot/handlers.py: 365 → 177 lines (-51% reduction) * Now contains only routing logic * Re-exports commands for backward compatibility * Cleaner, more focused responsibility Benefits: - Single Responsibility Principle achieved - Better testability (can test modules independently) - Improved organization and discoverability - Easier maintenance (changes isolated to specific files) - Full backward compatibility maintained Documented in MODULE_SEPARATION.md
This commit is contained in:
226
bot/handlers.py
226
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 = "📊 <b>Wandering Enemy Statistics</b>\n\n"
|
||||
text += f"<b>Total Active Enemies:</b> {stats['total_active']}\n\n"
|
||||
|
||||
if stats['by_location']:
|
||||
text += "<b>Enemies by Location:</b>\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 += "<i>No wandering enemies currently active.</i>"
|
||||
|
||||
await update.message.reply_html(text)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# BUTTON CALLBACK ROUTER
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user