@@ -133,9 +133,14 @@ def __init__(
133133 mention_aliases : Optional [Iterable [str ]] = None ,
134134 specs : Optional [Iterable [CommandSpec ]] = None ,
135135 auto_help : bool = True ,
136+ translator : Optional [Callable [[str ], str ]] = None ,
136137 ) -> None :
137138 self .prefixes = tuple (prefixes )
138139 self .enable_mentions = enable_mentions
140+ # Optional translator for built-in help strings; typically
141+ # this is BaseBot.tr or a similar function. It must accept
142+ # a single string key and return the translated string.
143+ self ._translator : Optional [Callable [[str ], str ]] = translator
139144 self .mention_aliases : List [str ] = []
140145 if mention_aliases :
141146 self .set_mentions (mention_aliases )
@@ -149,6 +154,9 @@ def __init__(
149154 self .register_spec (
150155 CommandSpec (
151156 name = "help" ,
157+ # Store the English key here and translate lazily
158+ # when rendering help, so that i18n initialization
159+ # order does not affect the final text.
152160 description = "Show available commands" ,
153161 aliases = ["?" ],
154162 args = [CommandArgument ("command" , str , required = False , description = "Command name for detailed help" )],
@@ -201,6 +209,26 @@ def parse_text(self, text: str) -> CommandInvocation:
201209 parsed_args = self ._parse_args (tokens [1 :], spec )
202210 return CommandInvocation (name = spec .name , args = parsed_args , tokens = tokens , spec = spec )
203211
212+ def find_command_spec (self , text : str ) -> Optional [CommandSpec ]:
213+ """Return the CommandSpec for a raw message text, if any.
214+
215+ This is a lightweight helper for callers that only need to know
216+ *which* command would be invoked (e.g., for permission checks)
217+ without fully parsing arguments or raising errors for unknown
218+ commands. It respects the same prefixes and @-mention rules as
219+ :meth:`parse_message`.
220+ """
221+
222+ stripped = self ._strip_prefix_or_mention (text )
223+ if stripped is None :
224+ return None
225+ tokens = stripped .split ()
226+ if not tokens :
227+ return None
228+ name_token = tokens [0 ].lower ()
229+ command_name = self .alias_index .get (name_token , name_token )
230+ return self .specs .get (command_name )
231+
204232 async def dispatch (self , invocation : CommandInvocation , * , message : Any , bot : SupportsSendReply ) -> None :
205233 handler = invocation .spec .handler
206234 if handler is None :
@@ -236,7 +264,9 @@ def _parse_args(self, args_tokens: List[str], spec: CommandSpec) -> Dict[str, An
236264 if idx >= len (args_tokens ):
237265 if arg_spec .required :
238266 usage = self ._format_usage (spec )
239- raise InvalidArgumentsError (spec .name , f"Missing argument: { arg_spec .name } \n Usage: { usage } " )
267+ msg = self ._tr ("Missing argument: {name}" ).format (name = arg_spec .name )
268+ usage_line = self ._tr ("Usage: {usage}" ).format (usage = usage )
269+ raise InvalidArgumentsError (spec .name , f"{ msg } \n { usage_line } " )
240270 parsed [arg_spec .name ] = None
241271 continue
242272
@@ -245,7 +275,9 @@ def _parse_args(self, args_tokens: List[str], spec: CommandSpec) -> Dict[str, An
245275
246276 if not spec .allow_extra and idx < len (args_tokens ):
247277 usage = self ._format_usage (spec )
248- raise InvalidArgumentsError (spec .name , f"Too many arguments\n Usage: { usage } " )
278+ msg = self ._tr ("Too many arguments" )
279+ usage_line = self ._tr ("Usage: {usage}" ).format (usage = usage )
280+ raise InvalidArgumentsError (spec .name , f"{ msg } \n { usage_line } " )
249281 return parsed
250282
251283 def _convert_value (self , value : str , arg_spec : CommandArgument ) -> ArgType :
@@ -263,7 +295,9 @@ def _convert_value(self, value: str, arg_spec: CommandArgument) -> ArgType:
263295 except InvalidArgumentsError :
264296 raise
265297 except Exception as exc : # pragma: no cover - simple conversion guard
266- raise InvalidArgumentsError (arg_spec .name , f"Invalid value for { arg_spec .name } : { value } " ) from exc
298+ template = self ._tr ("Invalid value for {name}: {value}" )
299+ message = template .format (name = arg_spec .name , value = value )
300+ raise InvalidArgumentsError (arg_spec .name , message ) from exc
267301
268302 @staticmethod
269303 def _to_bool (value : str ) -> bool :
@@ -274,30 +308,79 @@ def _to_bool(value: str) -> bool:
274308 return False
275309 raise ValueError (value )
276310
277- def generate_help (self ) -> str :
278- prefix = self .prefixes [0 ] if self .prefixes else ""
311+ def _tr (self , text : str ) -> str :
312+ """Translate a static help text if a translator was provided.
313+
314+ This keeps CommandParser decoupled from any particular i18n
315+ system while still allowing SDK users (like BaseBot) to
316+ provide a translation function. Failures are quietly ignored
317+ so they don't pollute logs on startup or in edge cases.
318+ """
319+
320+ if self ._translator is None :
321+ return text
322+ try :
323+ return self ._translator (text )
324+ except Exception : # pragma: no cover - defensive fallback only
325+ return text
326+
327+ def generate_help (self , * , user_level : Optional [int ] = None ) -> str :
279328 lines : List [str ] = []
280329 for spec in self .specs .values ():
281330 if not spec .show_in_help :
282331 continue
332+ # If a user level is provided and the command requires a higher
333+ # level, hide it from the help output to keep things simple.
334+ if user_level is not None and spec .min_level is not None and spec .min_level > user_level :
335+ continue
283336 summary = self ._format_usage (spec )
284337 if spec .description :
285- summary = f"{ summary } — { spec .description } "
338+ summary = f"{ summary } — { self . _tr ( spec .description ) } "
286339 lines .append (summary )
287340 return "\n " .join (lines ) if lines else "No commands registered."
288341
289342 async def _handle_help (self , invocation : CommandInvocation , message : Any , bot : SupportsSendReply ) -> None :
290343 # Default help handler: reply with generated help text.
344+ # Try to obtain the caller's permission level if the bot exposes it.
345+ user_level : Optional [int ] = None
346+ sender_id = getattr (message , "sender_id" , None )
347+ if sender_id is not None :
348+ level_getter = getattr (bot , "get_user_level" , None )
349+ if callable (level_getter ):
350+ try :
351+ maybe_result = level_getter (sender_id )
352+ if hasattr (maybe_result , "__await__" ):
353+ user_level = await maybe_result # type: ignore[assignment]
354+ else :
355+ user_level = int (maybe_result ) # type: ignore[arg-type]
356+ except Exception : # pragma: no cover - help should degrade gracefully
357+ user_level = None
358+
291359 target = invocation .args .get ("command" )
292360 if not target :
293- await bot .send_reply (message , self .generate_help ())
361+ await bot .send_reply (message , self .generate_help (user_level = user_level ))
294362 return
295363
296364 target_name = str (target ).lower ()
297365 spec_name = self .alias_index .get (target_name , target_name )
298366 spec = self .specs .get (spec_name )
299367 if not spec :
300- await bot .send_reply (message , f"Unknown command: { target } " )
368+ # Try to use bot-level i18n if available.
369+ tr = getattr (bot , "tr" , None )
370+ if callable (tr ):
371+ await bot .send_reply (message , tr ("Unknown command: {name}" , name = str (target )))
372+ else :
373+ await bot .send_reply (message , f"Unknown command: { target } " )
374+ return
375+
376+ # If we know the user's level and the command requires a higher level,
377+ # do not reveal full details.
378+ if user_level is not None and spec .min_level is not None and user_level < spec .min_level :
379+ tr = getattr (bot , "tr" , None )
380+ if callable (tr ):
381+ await bot .send_reply (message , tr ("You do not have permission to use command: {name}" , name = spec .name ))
382+ else :
383+ await bot .send_reply (message , f"You do not have permission to use command: { spec .name } " )
301384 return
302385
303386 detail = self ._format_spec_detail (spec )
@@ -321,16 +404,16 @@ def _format_spec_detail(self, spec: CommandSpec) -> str:
321404 lines : List [str ] = []
322405 lines .append (self ._format_usage (spec ))
323406 if spec .description :
324- lines .append (f"Description: { spec .description } " )
407+ lines .append (f"{ self . _tr ( ' Description' ) } : { self . _tr ( spec .description ) } " )
325408 if spec .aliases :
326- lines .append (f"Aliases: { ', ' .join (spec .aliases )} " )
409+ lines .append (f"{ self . _tr ( ' Aliases' ) } : { ', ' .join (spec .aliases )} " )
327410 if spec .min_level is not None :
328- lines .append (f"Min level: { spec .min_level } " )
411+ lines .append (f"{ self . _tr ( ' Min level' ) } : { spec .min_level } " )
329412 if spec .args :
330- lines .append ("Args:" )
413+ lines .append (self . _tr ( "Args:" ) )
331414 for arg in spec .args :
332- requirement = "required" if arg .required else "optional"
333- multi = " (multiple)" if arg .multiple else ""
415+ requirement = self . _tr ( "required" ) if arg .required else self . _tr ( "optional" )
416+ multi = f " ({ self . _tr ( ' multiple' ) } )" if arg .multiple else ""
334417 desc = f" - { arg .name } : { requirement } { multi } "
335418 validator_hint = self ._format_validator_hint (arg .validator )
336419 if arg .description :
@@ -351,6 +434,8 @@ def _format_validator_hint(validator: Optional[Validator]) -> str:
351434 return str (hint ) if hint else ""
352435 except Exception : # pragma: no cover - help rendering should not break help
353436 return ""
437+ # This is a developer-facing hint, not end-user text, so we
438+ # intentionally keep it simple and non-localized.
354439 return f"validated by { validator .__class__ .__name__ } "
355440
356441
0 commit comments