Files
echoes-of-the-ash/bot/handlers.py
Joan 861f3b8a36 Add visual progress bars and refactor handler modules
- Implement visual HP/Stamina/XP bars using Unicode characters (██░)
- Refactor handlers.py (1308 → 377 lines) into specialized modules:
  * action_handlers.py - World interaction and status display
  * inventory_handlers.py - Inventory management
  * combat_handlers.py - Combat actions
  * profile_handlers.py - Character stats with visual bars
  * corpse_handlers.py - Looting system
  * pickup_handlers.py - Item collection
- Add utility functions: create_progress_bar(), format_stat_bar()
- Organize all documentation into docs/ structure
- Create comprehensive documentation index with navigation
- Add UI examples showing before/after visual improvements
2025-10-19 00:23:44 +02:00

378 lines
14 KiB
Python

"""
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.
"""
import logging
import os
import json
from telegram import Update, InlineKeyboardMarkup, InputMediaPhoto
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
# Import organized action handlers
from .action_handlers import (
get_player_status_text,
handle_inspect_area,
handle_attack_wandering,
handle_inspect_interactable,
handle_action,
handle_main_menu,
handle_move_menu,
handle_move
)
from .inventory_handlers import (
handle_inventory_menu,
handle_inventory_item,
handle_inventory_use,
handle_inventory_drop,
handle_inventory_equip,
handle_inventory_unequip
)
from .pickup_handlers import (
handle_pickup_menu,
handle_pickup
)
from .combat_handlers import (
handle_combat_attack,
handle_combat_flee,
handle_combat_use_item_menu,
handle_combat_use_item,
handle_combat_back
)
from .profile_handlers import (
handle_profile,
handle_spend_points_menu,
handle_spend_point
)
from .corpse_handlers import (
handle_loot_player_corpse,
handle_take_corpse_item,
handle_scavenge_npc_corpse,
handle_scavenge_corpse_item
)
logger = logging.getLogger(__name__)
# ============================================================================
# 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
# ============================================================================
async def button_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
Main router for button callbacks.
Delegates to specific handler functions based on action type.
"""
query = update.callback_query
user_id = query.from_user.id
data = query.data.split(':')
action_type = data[0]
player = await database.get_player(user_id)
if not player or player['is_dead']:
await query.answer()
await send_or_edit_with_image(
query,
text="💀 Your journey has ended. You died in the wasteland. Create a new character with /start to begin again.",
reply_markup=None
)
return
# Check if player is in combat - restrict most actions
combat = await database.get_combat(user_id)
allowed_in_combat = [
'combat_attack', 'combat_flee', 'combat_use_item_menu',
'combat_use_item', 'combat_back', 'no_op'
]
if combat and action_type not in allowed_in_combat:
await query.answer("You're in combat! Focus on the fight!", show_alert=False)
return
# Route to appropriate handler based on action type
try:
# Inspection & World Interaction
if action_type == "inspect_area":
await handle_inspect_area(query, user_id, player)
elif action_type == "attack_wandering":
await handle_attack_wandering(query, user_id, player, data)
elif action_type == "inspect":
await handle_inspect_interactable(query, user_id, player, data)
elif action_type == "action":
await handle_action(query, user_id, player, data)
elif action_type == "inspect_area_menu":
await handle_inspect_area(query, user_id, player)
# Navigation & Menu
elif action_type == "main_menu":
await handle_main_menu(query, user_id, player)
elif action_type == "move_menu":
await handle_move_menu(query, user_id, player)
elif action_type == "move":
await handle_move(query, user_id, player, data)
# Profile & Stats
elif action_type == "profile":
await handle_profile(query, user_id, player)
elif action_type == "spend_points_menu":
await handle_spend_points_menu(query, user_id, player)
elif action_type == "spend_point":
await handle_spend_point(query, user_id, player, data)
# Inventory Management
elif action_type == "inventory_menu":
await handle_inventory_menu(query, user_id, player)
elif action_type == "inventory_item":
await handle_inventory_item(query, user_id, player, data)
elif action_type == "inventory_use":
await handle_inventory_use(query, user_id, player, data)
elif action_type == "inventory_drop":
await handle_inventory_drop(query, user_id, player, data)
elif action_type == "inventory_equip":
await handle_inventory_equip(query, user_id, player, data)
elif action_type == "inventory_unequip":
await handle_inventory_unequip(query, user_id, player, data)
# Item Pickup
elif action_type == "pickup_menu":
await handle_pickup_menu(query, user_id, player, data)
elif action_type == "pickup":
await handle_pickup(query, user_id, player, data)
# Combat Actions
elif action_type == "combat_attack":
await handle_combat_attack(query, user_id, player)
elif action_type == "combat_flee":
await handle_combat_flee(query, user_id, player)
elif action_type == "combat_use_item_menu":
await handle_combat_use_item_menu(query, user_id, player)
elif action_type == "combat_use_item":
await handle_combat_use_item(query, user_id, player, data)
elif action_type == "combat_back":
await handle_combat_back(query, user_id, player)
# Corpse Looting
elif action_type == "loot_player_corpse":
await handle_loot_player_corpse(query, user_id, player, data)
elif action_type == "take_corpse_item":
await handle_take_corpse_item(query, user_id, player, data)
elif action_type == "scavenge_npc_corpse":
await handle_scavenge_npc_corpse(query, user_id, player, data)
elif action_type == "scavenge_corpse_item":
await handle_scavenge_corpse_item(query, user_id, player, data)
# No-op (for disabled buttons)
elif action_type == "no_op":
await query.answer()
else:
logger.warning(f"Unknown action type: {action_type}")
await query.answer("Unknown action", show_alert=False)
except Exception as e:
logger.error(f"Error handling button action {action_type}: {e}", exc_info=True)
await query.answer("An error occurred. Please try again.", show_alert=True)