Skip to content

Commit aa40a1d

Browse files
authored
Merge pull request #13 from agessaman/dev-localization
Localization and some international functionality improvements.
2 parents 46cbf3f + bfd3336 commit aa40a1d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+11773
-530
lines changed

config.ini.example

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,21 @@ bot_latitude = 40.7128
8585
# Example: -74.0060 for New York City, -123.00 for Victoria BC
8686
bot_longitude = -74.0060
8787

88+
[Localization]
89+
# Language code for bot responses (en, es, es-MX, es-ES, fr, de, ja, etc.)
90+
# Default: en (English)
91+
# The bot will use translations from translations/{language}.json
92+
# Supports locale codes:
93+
# - Simple codes: en, es, fr, de, ja
94+
# - Locale codes: es-MX (Mexican Spanish), es-ES (Spain Spanish), fr-CA (Canadian French)
95+
# If locale-specific file not found, falls back to base language (e.g., es.json)
96+
# If translation file is missing or key not found, falls back to English
97+
language = en
98+
99+
# Path to translation files directory (relative to bot root)
100+
# Default: translations/
101+
translation_path = translations/
102+
88103
[Admin_ACL]
89104
# Admin Access Control List (ACL) for restricted commands
90105
# Only users with public keys listed here can execute admin commands
@@ -171,14 +186,16 @@ advert_interval_hours = 0
171186

172187
[Keywords]
173188
# Keyword-response pairs (keyword = response format)
174-
# Available fields: {sender}, {connection_info}, {snr}, {timestamp}, {path}
189+
# Available fields: {sender}, {connection_info}, {snr}, {timestamp}, {path}, {path_distance}, {firstlast_distance}
175190
# {sender}: Name/ID of message sender
176191
# {connection_info}: "Direct connection (0 hops)" or "Routed through X hops"
177192
# {snr}: Signal-to-noise ratio in dB
178193
# {timestamp}: Message timestamp in HH:MM:SS format
179194
# {path}: Message routing path (e.g., "01,5f (2 hops)")
180195
# {rssi}: Received Signal Strength Indicator in dBm
181-
test = "ack {sender}{phrase_part} | {connection_info} | Received at: {timestamp}"
196+
# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)")
197+
# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing)
198+
test = "ack @[{sender}]{phrase_part} | {connection_info} | Received at: {timestamp}"
182199
ping = "Pong!"
183200
pong = "Ping!"
184201
help = "Bot Help: test (or t), ping, help, hello, cmd, advert, @string, wx, aqi, sun, moon, solar, hfcond, satpass, prefix, path, sports, dice, roll, stats | Use 'help <command>' for details"
@@ -235,8 +252,10 @@ meshcore_log_level = INFO
235252
[Custom_Syntax]
236253
# Custom syntax patterns for special message formats
237254
# Format: pattern = "response_format"
238-
# Available fields: {sender}, {phrase}, {connection_info}, {snr}, {timestamp}, {path}
255+
# Available fields: {sender}, {phrase}, {connection_info}, {snr}, {timestamp}, {path}, {path_distance}, {firstlast_distance}
239256
# {phrase}: The text after the trigger (for custom syntax patterns)
257+
# {path_distance}: Total distance between all hops in path with locations (e.g., "123.4km (3 segs, 1 no-loc)")
258+
# {firstlast_distance}: Distance between first and last repeater in path (e.g., "45.6km" or empty if locations missing)
240259
#
241260
# Note: The "t" command is now handled by the test command as an alias
242261
# "t phrase" works the same as "test phrase" - both use the test response format

modules/command_manager.py

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -145,19 +145,28 @@ def check_keywords(self, message: MeshMessage) -> List[tuple]:
145145
content_lower = content.lower()
146146

147147
# Check for help requests first (special handling)
148-
if content_lower.startswith('help '):
149-
command_name = content_lower[5:].strip() # Remove "help " prefix
150-
help_text = self.get_help_for_command(command_name, message)
151-
# Format the help response with message data (same as other keywords)
152-
help_text = self.format_keyword_response(help_text, message)
153-
matches.append(('help', help_text))
154-
return matches
155-
elif content_lower == 'help':
156-
help_text = self.get_general_help()
157-
# Format the help response with message data (same as other keywords)
158-
help_text = self.format_keyword_response(help_text, message)
159-
matches.append(('help', help_text))
160-
return matches
148+
# Check both English "help" and translated help keywords
149+
help_keywords = ['help']
150+
if 'help' in self.commands:
151+
help_command = self.commands['help']
152+
if hasattr(help_command, 'keywords'):
153+
help_keywords = [k.lower() for k in help_command.keywords]
154+
155+
# Check if message starts with any help keyword
156+
for help_keyword in help_keywords:
157+
if content_lower.startswith(help_keyword + ' '):
158+
command_name = content_lower[len(help_keyword):].strip() # Remove help keyword prefix
159+
help_text = self.get_help_for_command(command_name, message)
160+
# Format the help response with message data (same as other keywords)
161+
help_text = self.format_keyword_response(help_text, message)
162+
matches.append(('help', help_text))
163+
return matches
164+
elif content_lower == help_keyword:
165+
help_text = self.get_general_help()
166+
# Format the help response with message data (same as other keywords)
167+
help_text = self.format_keyword_response(help_text, message)
168+
matches.append(('help', help_text))
169+
return matches
161170

162171
# Check all loaded plugins for matches
163172
for command_name, command in self.commands.items():
@@ -360,6 +369,9 @@ def get_help_for_command(self, command_name: str, message: MeshMessage = None) -
360369
except TypeError:
361370
# Fallback for commands that don't accept message parameter
362371
help_text = command.get_help_text()
372+
# Use translator if available
373+
if hasattr(self.bot, 'translator'):
374+
return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text)
363375
return f"Help {command_name}: {help_text}"
364376

365377
# If not found, search through all commands and their keywords
@@ -372,6 +384,9 @@ def get_help_for_command(self, command_name: str, message: MeshMessage = None) -
372384
except TypeError:
373385
# Fallback for commands that don't accept message parameter
374386
help_text = cmd_instance.get_help_text()
387+
# Use translator if available
388+
if hasattr(self.bot, 'translator'):
389+
return self.bot.translator.translate('commands.help.specific', command=command_name, help_text=help_text)
375390
return f"Help {command_name}: {help_text}"
376391

377392
# If still not found, return unknown command message with helpful suggestion
@@ -381,7 +396,10 @@ def get_help_for_command(self, command_name: str, message: MeshMessage = None) -
381396
if hasattr(cmd_instance, 'keywords'):
382397
available_commands.extend(cmd_instance.keywords)
383398

384-
return f"Unknown: {command_name}. Available: {', '.join(sorted(set(available_commands)))}. Try 'help' for command list."
399+
available_str = ', '.join(sorted(set(available_commands)))
400+
if hasattr(self.bot, 'translator'):
401+
return self.bot.translator.translate('commands.help.unknown', command=command_name, available=available_str)
402+
return f"Unknown: {command_name}. Available: {available_str}. Try 'help' for command list."
385403

386404
def get_general_help(self) -> str:
387405
"""Get general help text from config (LoRa-friendly compact format)"""
@@ -481,9 +499,11 @@ async def execute_commands(self, message):
481499
# Check if command can execute (cooldown, DM requirements, etc.)
482500
if not command.can_execute_now(message):
483501
if command.requires_dm and not message.is_dm:
484-
await self.send_response(message, f"Command '{command_name}' can only be used in DMs")
502+
error_msg = command.translate('errors.dm_only', command=command_name)
503+
await self.send_response(message, error_msg)
485504
elif command.requires_admin_access():
486-
await self.send_response(message, f"❌ Access denied: Command '{command_name}' requires admin privileges")
505+
error_msg = command.translate('errors.access_denied', command=command_name)
506+
await self.send_response(message, error_msg)
487507
elif hasattr(command, 'get_remaining_cooldown') and callable(command.get_remaining_cooldown):
488508
# Check if it's the per-user version (takes user_id parameter)
489509
import inspect
@@ -494,7 +514,8 @@ async def execute_commands(self, message):
494514
remaining = command.get_remaining_cooldown()
495515

496516
if remaining > 0:
497-
await self.send_response(message, f"Command '{command_name}' is on cooldown. Wait {remaining} seconds.")
517+
error_msg = command.translate('errors.cooldown', command=command_name, seconds=remaining)
518+
await self.send_response(message, error_msg)
498519
return
499520

500521
try:
@@ -536,7 +557,8 @@ async def execute_commands(self, message):
536557
except Exception as e:
537558
self.logger.error(f"Error executing command '{command_name}': {e}")
538559
# Send error message to user
539-
await self.send_response(message, f"Error executing {command_name}: {e}")
560+
error_msg = command.translate('errors.execution_error', command=command_name, error=str(e))
561+
await self.send_response(message, error_msg)
540562

541563
# Capture failed command for web viewer
542564
if (hasattr(self.bot, 'web_viewer_integration') and

modules/commands/advert_command.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class AdvertCommand(BaseCommand):
2121
category = "special"
2222

2323
def get_help_text(self) -> str:
24-
return self.description
24+
return self.translate('commands.advert.description')
2525

2626
def can_execute(self, message: MeshMessage) -> bool:
2727
"""Check if advert command can be executed"""
@@ -45,7 +45,7 @@ async def execute(self, message: MeshMessage) -> bool:
4545
if hasattr(self.bot, 'last_advert_time') and self.bot.last_advert_time and (current_time - self.bot.last_advert_time) < 3600:
4646
remaining_time = 3600 - (current_time - self.bot.last_advert_time)
4747
remaining_minutes = int(remaining_time // 60)
48-
response = f"Advert cooldown active. Please wait {remaining_minutes} more minutes before requesting another advert."
48+
response = self.translate('commands.advert.cooldown_active', minutes=remaining_minutes)
4949
await self.send_response(message, response)
5050
return True
5151

@@ -58,14 +58,14 @@ async def execute(self, message: MeshMessage) -> bool:
5858
if hasattr(self.bot, 'last_advert_time'):
5959
self.bot.last_advert_time = current_time
6060

61-
response = "Flood advert sent successfully!"
61+
response = self.translate('commands.advert.success')
6262
self.logger.info("Flood advert sent successfully via DM command")
6363

6464
await self.send_response(message, response)
6565
return True
6666

6767
except Exception as e:
68-
error_msg = f"Error sending flood advert: {e}"
69-
self.logger.error(error_msg)
68+
error_msg = self.translate('commands.advert.error', error=str(e))
69+
self.logger.error(f"Error sending flood advert: {e}")
7070
await self.send_response(message, error_msg)
7171
return False

0 commit comments

Comments
 (0)