Skip to content

Commit ab08a68

Browse files
authored
Merge pull request #2 from Open-LLM-VTuber/dev
Dev
2 parents 211b47a + 0656a06 commit ab08a68

16 files changed

Lines changed: 970 additions & 683 deletions

File tree

bot_sdk/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
)
1414
from .runner import BotRunner
1515
from .logging import setup_logging
16+
from .i18n import I18n, build_i18n_for_bot
1617
from .storage import BotStorage, CachedStorage
1718
from .config import StorageConfig
1819
from .models import (
@@ -85,4 +86,6 @@
8586
"create_sessionmaker",
8687
"session_scope",
8788
"AsyncRepository",
89+
"I18n",
90+
"build_i18n_for_bot",
8891
]

bot_sdk/bot.py

Lines changed: 223 additions & 30 deletions
Large diffs are not rendered by default.

bot_sdk/commands.py

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -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}\nUsage: {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\nUsage: {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

bot_sdk/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,11 @@ class BotLocalConfig(BaseModel):
5151
- owner_user_id: explicit bot owner (Zulip user_id), independent of org owners
5252
- role_levels: mapping of role name -> numeric level (higher is more privileged)
5353
- settings: extra arbitrary settings for the bot
54+
- language: default language/locale code for this bot (e.g. "en", "zh")
5455
"""
5556

5657
owner_user_id: Optional[int] = None
58+
language: str = "en"
5759
role_levels: dict[str, int] = Field(
5860
default_factory=lambda: {
5961
"user": 1,

bot_sdk/db/migrations/env.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import importlib
45
import os
56
from logging.config import fileConfig
67
from pathlib import Path
@@ -16,6 +17,41 @@
1617
if config.config_file_name is not None:
1718
fileConfig(config.config_file_name)
1819

20+
21+
def _load_bot_models() -> None:
22+
"""Best-effort import of bot-local ORM models.
23+
24+
Convention:
25+
- Per-bot Alembic scripts live in ``bots/<bot_name>/migrations``
26+
- ORM models for that bot live in ``bots/<bot_name>/models.py``
27+
28+
When this env.py is copied into a bot's migrations directory,
29+
this helper will detect the ``bots/<bot_name>`` layout from
30+
``__file__`` and import ``bots.<bot_name>.models`` so that any
31+
models subclassing ``bot_sdk.db.database.Base`` are registered
32+
on ``Base.metadata`` for Alembic's autogenerate.
33+
"""
34+
35+
env_path = Path(__file__).resolve()
36+
37+
# Expect .../bots/<bot_name>/migrations/env.py
38+
try:
39+
if env_path.parent.name != "migrations":
40+
return
41+
bot_dir = env_path.parent.parent
42+
if bot_dir.parent.name != "bots":
43+
return
44+
bot_name = bot_dir.name
45+
module_name = f"bots.{bot_name}.models"
46+
importlib.import_module(module_name)
47+
except Exception:
48+
# Never break migrations just because models cannot be loaded;
49+
# bots that do not use ORM can safely ignore this.
50+
return
51+
52+
53+
_load_bot_models()
54+
1955
# Metadata for autogenerate
2056
# Add ORM models to Base to include them in migrations
2157

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""Alembic revision script.
2+
3+
This file was generated by async-zulip-bot-sdk's Alembic template.
4+
5+
${message if message else ""}
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from typing import Optional, Sequence
11+
12+
from alembic import op
13+
import sqlalchemy as sa
14+
15+
# Revision identifiers, used by Alembic.
16+
revision: str = "${up_revision}"
17+
down_revision: Optional[str] = ${repr(down_revision)}
18+
branch_labels: Optional[Sequence[str]] = ${repr(branch_labels)}
19+
depends_on: Optional[str] = ${repr(depends_on)}
20+
21+
22+
def upgrade() -> None:
23+
"""Apply schema changes for this revision."""
24+
${upgrades if upgrades else "pass"}
25+
26+
27+
def downgrade() -> None:
28+
"""Revert schema changes for this revision."""
29+
${downgrades if downgrades else "pass"}

0 commit comments

Comments
 (0)