Add persona and language command localization

This commit is contained in:
Hua
2026-03-13 11:29:08 +08:00
parent b2584dd2cf
commit 83826f3904
11 changed files with 742 additions and 55 deletions

View File

@@ -8,7 +8,15 @@ from datetime import datetime
from pathlib import Path
from typing import Any
from nanobot.agent.i18n import language_label, resolve_language
from nanobot.agent.memory import MemoryStore
from nanobot.agent.personas import (
DEFAULT_PERSONA,
list_personas,
persona_workspace,
personas_root,
resolve_persona_name,
)
from nanobot.agent.skills import SkillsLoader
from nanobot.utils.helpers import build_assistant_message, detect_image_mime
@@ -21,18 +29,36 @@ class ContextBuilder:
def __init__(self, workspace: Path):
self.workspace = workspace
self.memory = MemoryStore(workspace)
self.skills = SkillsLoader(workspace)
def build_system_prompt(self, skill_names: list[str] | None = None) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
parts = [self._get_identity()]
def list_personas(self) -> list[str]:
"""Return the personas available for this workspace."""
return list_personas(self.workspace)
bootstrap = self._load_bootstrap_files()
def find_persona(self, persona: str | None) -> str | None:
"""Resolve a persona name without applying a default fallback."""
return resolve_persona_name(self.workspace, persona)
def resolve_persona(self, persona: str | None) -> str:
"""Return a canonical persona name, defaulting to the built-in persona."""
return self.find_persona(persona) or DEFAULT_PERSONA
def build_system_prompt(
self,
skill_names: list[str] | None = None,
persona: str | None = None,
language: str | None = None,
) -> str:
"""Build the system prompt from identity, bootstrap files, memory, and skills."""
active_persona = self.resolve_persona(persona)
active_language = resolve_language(language)
parts = [self._get_identity(active_persona, active_language)]
bootstrap = self._load_bootstrap_files(active_persona)
if bootstrap:
parts.append(bootstrap)
memory = self.memory.get_memory_context()
memory = self._memory_store(active_persona).get_memory_context()
if memory:
parts.append(f"# Memory\n\n{memory}")
@@ -53,9 +79,12 @@ Skills with available="false" need dependencies installed first - you can try in
return "\n\n---\n\n".join(parts)
def _get_identity(self) -> str:
def _get_identity(self, persona: str, language: str) -> str:
"""Get the core identity section."""
workspace_path = str(self.workspace.expanduser().resolve())
active_workspace = persona_workspace(self.workspace, persona)
persona_path = str(active_workspace.expanduser().resolve())
language_name = language_label(language, language)
system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
@@ -81,10 +110,18 @@ You are nanobot, a helpful AI assistant.
## Workspace
Your workspace is at: {workspace_path}
- Long-term memory: {workspace_path}/memory/MEMORY.md (write important facts here)
- History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM].
- Long-term memory: {persona_path}/memory/MEMORY.md (write important facts here)
- History log: {persona_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM].
- Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md
## Persona
Current persona: {persona}
- Persona workspace: {persona_path}
## Language
Preferred response language: {language_name}
- Use this language for assistant replies and command/status text unless the user explicitly asks for another language.
{platform_policy}
## nanobot Guidelines
@@ -106,12 +143,21 @@ Reply directly with text for conversations. Only use the 'message' tool to send
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines)
def _load_bootstrap_files(self) -> str:
def _memory_store(self, persona: str) -> MemoryStore:
"""Return the memory store for the active persona."""
return MemoryStore(persona_workspace(self.workspace, persona))
def _load_bootstrap_files(self, persona: str) -> str:
"""Load all bootstrap files from workspace."""
parts = []
persona_dir = None if persona == DEFAULT_PERSONA else personas_root(self.workspace) / persona
for filename in self.BOOTSTRAP_FILES:
file_path = self.workspace / filename
if persona_dir:
persona_file = persona_dir / filename
if persona_file.exists():
file_path = persona_file
if file_path.exists():
content = file_path.read_text(encoding="utf-8")
parts.append(f"## {filename}\n\n{content}")
@@ -126,6 +172,8 @@ Reply directly with text for conversations. Only use the 'message' tool to send
media: list[str] | None = None,
channel: str | None = None,
chat_id: str | None = None,
persona: str | None = None,
language: str | None = None,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
runtime_ctx = self._build_runtime_context(channel, chat_id)
@@ -139,7 +187,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send
merged = [{"type": "text", "text": runtime_ctx}] + user_content
return [
{"role": "system", "content": self.build_system_prompt(skill_names)},
{"role": "system", "content": self.build_system_prompt(skill_names, persona=persona, language=language)},
*history,
{"role": "user", "content": merged},
]

91
nanobot/agent/i18n.py Normal file
View File

@@ -0,0 +1,91 @@
"""Minimal session-level localization helpers."""
from __future__ import annotations
import json
from functools import lru_cache
from importlib.resources import files as pkg_files
from typing import Any
DEFAULT_LANGUAGE = "en"
SUPPORTED_LANGUAGES = ("en", "zh")
_LANGUAGE_ALIASES = {
"en": "en",
"en-us": "en",
"en-gb": "en",
"english": "en",
"zh": "zh",
"zh-cn": "zh",
"zh-hans": "zh",
"zh-sg": "zh",
"cn": "zh",
"chinese": "zh",
"中文": "zh",
}
@lru_cache(maxsize=len(SUPPORTED_LANGUAGES))
def _load_locale(language: str) -> dict[str, Any]:
"""Load one locale file from packaged JSON resources."""
lang = resolve_language(language)
locale_file = pkg_files("nanobot") / "locales" / f"{lang}.json"
with locale_file.open("r", encoding="utf-8") as fh:
return json.load(fh)
def normalize_language_code(value: Any) -> str | None:
"""Normalize a language identifier into a supported code."""
if not isinstance(value, str):
return None
cleaned = value.strip().lower()
if not cleaned:
return None
return _LANGUAGE_ALIASES.get(cleaned)
def resolve_language(value: Any) -> str:
"""Resolve the active language, defaulting to English."""
return normalize_language_code(value) or DEFAULT_LANGUAGE
def list_languages() -> list[str]:
"""Return supported language codes in display order."""
return list(SUPPORTED_LANGUAGES)
def language_label(code: str, ui_language: str | None = None) -> str:
"""Return a display label for a language code."""
active_ui = resolve_language(ui_language)
normalized = resolve_language(code)
locale = _load_locale(active_ui)
return f"{normalized} ({locale['language_labels'][normalized]})"
def text(language: Any, key: str, **kwargs: Any) -> str:
"""Return localized UI text."""
active = resolve_language(language)
template = _load_locale(active)["texts"][key]
return template.format(**kwargs)
def help_lines(language: Any) -> list[str]:
"""Return localized slash-command help lines."""
active = resolve_language(language)
return [
text(active, "help_header"),
text(active, "cmd_new"),
text(active, "cmd_lang_current"),
text(active, "cmd_lang_list"),
text(active, "cmd_lang_set"),
text(active, "cmd_persona_current"),
text(active, "cmd_persona_list"),
text(active, "cmd_persona_set"),
text(active, "cmd_stop"),
text(active, "cmd_restart"),
text(active, "cmd_help"),
]
def telegram_command_descriptions(language: Any) -> dict[str, str]:
"""Return Telegram command descriptions for a locale."""
return _load_locale(resolve_language(language))["telegram_commands"]

View File

@@ -14,6 +14,15 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
from loguru import logger
from nanobot.agent.context import ContextBuilder
from nanobot.agent.i18n import (
DEFAULT_LANGUAGE,
help_lines,
language_label,
list_languages,
normalize_language_code,
resolve_language,
text,
)
from nanobot.agent.memory import MemoryConsolidator
from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.cron import CronTool
@@ -119,6 +128,52 @@ class AgentLoop:
)
self._register_default_tools()
@staticmethod
def _command_name(content: str) -> str:
"""Return the normalized slash command name."""
parts = content.strip().split(None, 1)
return parts[0].lower() if parts else ""
def _get_session_persona(self, session: Session) -> str:
"""Return the active persona name for a session."""
return self.context.resolve_persona(session.metadata.get("persona"))
def _get_session_language(self, session: Session) -> str:
"""Return the active language for a session."""
metadata = getattr(session, "metadata", {})
raw = metadata.get("language") if isinstance(metadata, dict) else DEFAULT_LANGUAGE
return resolve_language(raw)
def _set_session_persona(self, session: Session, persona: str) -> None:
"""Persist the selected persona for a session."""
if persona == "default":
session.metadata.pop("persona", None)
else:
session.metadata["persona"] = persona
def _set_session_language(self, session: Session, language: str) -> None:
"""Persist the selected language for a session."""
if language == DEFAULT_LANGUAGE:
session.metadata.pop("language", None)
else:
session.metadata["language"] = language
def _persona_usage(self, language: str) -> str:
"""Return persona command help text."""
return "\n".join([
text(language, "cmd_persona_current"),
text(language, "cmd_persona_list"),
text(language, "cmd_persona_set"),
])
def _language_usage(self, language: str) -> str:
"""Return language command help text."""
return "\n".join([
text(language, "cmd_lang_current"),
text(language, "cmd_lang_list"),
text(language, "cmd_lang_set"),
])
def _register_default_tools(self) -> None:
"""Register the default set of tools."""
allowed_dir = self.workspace if self.restrict_to_workspace else None
@@ -275,7 +330,7 @@ class AgentLoop:
except asyncio.TimeoutError:
continue
cmd = msg.content.strip().lower()
cmd = self._command_name(msg.content)
if cmd == "/stop":
await self._handle_stop(msg)
elif cmd == "/restart":
@@ -296,15 +351,19 @@ class AgentLoop:
pass
sub_cancelled = await self.subagents.cancel_by_session(msg.session_key)
total = cancelled + sub_cancelled
content = f"Stopped {total} task(s)." if total else "No active task to stop."
session = self.sessions.get_or_create(msg.session_key)
language = self._get_session_language(session)
content = text(language, "stopped_tasks", count=total) if total else text(language, "no_active_task")
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
))
async def _handle_restart(self, msg: InboundMessage) -> None:
"""Restart the process in-place via os.execv."""
session = self.sessions.get_or_create(msg.session_key)
language = self._get_session_language(session)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="Restarting...",
channel=msg.channel, chat_id=msg.chat_id, content=text(language, "restarting"),
))
async def _do_restart():
@@ -334,9 +393,145 @@ class AgentLoop:
logger.exception("Error processing message for session {}", msg.session_key)
await self.bus.publish_outbound(OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id,
content="Sorry, I encountered an error.",
content=text(self._get_session_language(self.sessions.get_or_create(msg.session_key)), "generic_error"),
))
async def _handle_language_command(self, msg: InboundMessage, session: Session) -> OutboundMessage:
"""Handle session-scoped language switching commands."""
current = self._get_session_language(session)
parts = msg.content.strip().split()
if len(parts) == 1 or parts[1].lower() == "current":
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(current, "current_language", language_name=language_label(current, current)),
)
subcommand = parts[1].lower()
if subcommand == "list":
items = "\n".join(
f"- {language_label(code, current)}"
+ (f" ({text(current, 'current_marker')})" if code == current else "")
for code in list_languages()
)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(current, "available_languages", items=items),
)
if subcommand != "set" or len(parts) < 3:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=self._language_usage(current),
)
target = normalize_language_code(parts[2])
if target is None:
languages = ", ".join(language_label(code, current) for code in list_languages())
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(current, "unknown_language", name=parts[2], languages=languages),
)
if target == current:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(current, "language_already_active", language_name=language_label(target, current)),
)
self._set_session_language(session, target)
self.sessions.save(session)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(target, "switched_language", language_name=language_label(target, target)),
)
async def _handle_persona_command(self, msg: InboundMessage, session: Session) -> OutboundMessage:
"""Handle session-scoped persona management commands."""
language = self._get_session_language(session)
parts = msg.content.strip().split()
if len(parts) == 1 or parts[1].lower() == "current":
current = self._get_session_persona(session)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "current_persona", persona=current),
)
subcommand = parts[1].lower()
if subcommand == "list":
current = self._get_session_persona(session)
marker = text(language, "current_marker")
personas = [
f"{name} ({marker})" if name == current else name
for name in self.context.list_personas()
]
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "available_personas", items="\n".join(f"- {name}" for name in personas)),
)
if subcommand != "set" or len(parts) < 3:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=self._persona_usage(language),
)
target = self.context.find_persona(parts[2])
if target is None:
personas = ", ".join(self.context.list_personas())
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(
language,
"unknown_persona",
name=parts[2],
personas=personas,
path=self.workspace / "personas" / parts[2],
),
)
current = self._get_session_persona(session)
if target == current:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "persona_already_active", persona=target),
)
try:
if not await self.memory_consolidator.archive_unconsolidated(session):
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "memory_archival_failed_persona"),
)
except Exception:
logger.exception("/persona archival failed for {}", session.key)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "memory_archival_failed_persona"),
)
session.clear()
self._set_session_persona(session, target)
self.sessions.save(session)
self.sessions.invalidate(session.key)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content=text(language, "switched_persona", persona=target),
)
async def close_mcp(self) -> None:
"""Close MCP connections."""
if self._mcp_stack:
@@ -365,12 +560,18 @@ class AgentLoop:
logger.info("Processing system message from {}", msg.sender_id)
key = f"{channel}:{chat_id}"
session = self.sessions.get_or_create(key)
persona = self._get_session_persona(session)
language = self._get_session_language(session)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
history = session.get_history(max_messages=0)
messages = self.context.build_messages(
history=history,
current_message=msg.content, channel=channel, chat_id=chat_id,
current_message=msg.content,
channel=channel,
chat_id=chat_id,
persona=persona,
language=language,
)
final_content, _, all_msgs = await self._run_agent_loop(messages)
self._save_turn(session, all_msgs, 1 + len(history))
@@ -384,40 +585,39 @@ class AgentLoop:
key = session_key or msg.session_key
session = self.sessions.get_or_create(key)
persona = self._get_session_persona(session)
language = self._get_session_language(session)
# Slash commands
cmd = msg.content.strip().lower()
cmd = self._command_name(msg.content)
if cmd == "/new":
try:
if not await self.memory_consolidator.archive_unconsolidated(session):
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
content=text(language, "memory_archival_failed_session"),
)
except Exception:
logger.exception("/new archival failed for {}", session.key)
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
content="Memory archival failed, session not cleared. Please try again.",
content=text(language, "memory_archival_failed_session"),
)
session.clear()
self.sessions.save(session)
self.sessions.invalidate(session.key)
return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id,
content="New session started.")
content=text(language, "new_session_started"))
if cmd in {"/lang", "/language"}:
return await self._handle_language_command(msg, session)
if cmd == "/persona":
return await self._handle_persona_command(msg, session)
if cmd == "/help":
lines = [
"🐈 nanobot commands:",
"/new — Start a new conversation",
"/stop — Stop the current task",
"/restart — Restart the bot",
"/help — Show available commands",
]
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines),
channel=msg.channel, chat_id=msg.chat_id, content="\n".join(help_lines(language)),
)
await self.memory_consolidator.maybe_consolidate_by_tokens(session)
@@ -431,7 +631,10 @@ class AgentLoop:
history=history,
current_message=msg.content,
media=msg.media if msg.media else None,
channel=msg.channel, chat_id=msg.chat_id,
channel=msg.channel,
chat_id=msg.chat_id,
persona=persona,
language=language,
)
async def _bus_progress(content: str, *, tool_hint: bool = False) -> None:

View File

@@ -10,6 +10,8 @@ from typing import TYPE_CHECKING, Any, Callable
from loguru import logger
from nanobot.agent.i18n import DEFAULT_LANGUAGE, resolve_language
from nanobot.agent.personas import DEFAULT_PERSONA, persona_workspace, resolve_persona_name
from nanobot.utils.helpers import ensure_dir, estimate_message_tokens, estimate_prompt_tokens_chain
if TYPE_CHECKING:
@@ -71,6 +73,7 @@ def _is_tool_choice_unsupported(content: str | None) -> bool:
return any(m in text for m in _TOOL_CHOICE_ERROR_MARKERS)
class MemoryStore:
"""Two-layer memory: MEMORY.md (long-term facts) + HISTORY.md (grep-searchable log)."""
@@ -195,7 +198,7 @@ class MemoryConsolidator:
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
):
self.store = MemoryStore(workspace)
self.workspace = workspace
self.provider = provider
self.model = model
self.sessions = sessions
@@ -204,13 +207,27 @@ class MemoryConsolidator:
self._get_tool_definitions = get_tool_definitions
self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary()
def _get_persona(self, session: Session) -> str:
"""Resolve the active persona for a session."""
return resolve_persona_name(self.workspace, session.metadata.get("persona")) or DEFAULT_PERSONA
def _get_language(self, session: Session) -> str:
"""Resolve the active language for a session."""
metadata = getattr(session, "metadata", {})
raw = metadata.get("language") if isinstance(metadata, dict) else DEFAULT_LANGUAGE
return resolve_language(raw)
def _get_store(self, session: Session) -> MemoryStore:
"""Return the memory store associated with the active persona."""
return MemoryStore(persona_workspace(self.workspace, self._get_persona(session)))
def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock())
async def consolidate_messages(self, messages: list[dict[str, object]]) -> bool:
async def consolidate_messages(self, session: Session, messages: list[dict[str, object]]) -> bool:
"""Archive a selected message chunk into persistent memory."""
return await self.store.consolidate(messages, self.provider, self.model)
return await self._get_store(session).consolidate(messages, self.provider, self.model)
def pick_consolidation_boundary(
self,
@@ -243,6 +260,8 @@ class MemoryConsolidator:
current_message="[token-probe]",
channel=channel,
chat_id=chat_id,
persona=self._get_persona(session),
language=self._get_language(session),
)
return estimate_prompt_tokens_chain(
self.provider,
@@ -258,7 +277,7 @@ class MemoryConsolidator:
snapshot = session.messages[session.last_consolidated:]
if not snapshot:
return True
return await self.consolidate_messages(snapshot)
return await self.consolidate_messages(session, snapshot)
async def maybe_consolidate_by_tokens(self, session: Session) -> None:
"""Loop: archive old messages until prompt fits within half the context window."""
@@ -308,7 +327,7 @@ class MemoryConsolidator:
source,
len(chunk),
)
if not await self.consolidate_messages(chunk):
if not await self.consolidate_messages(session, chunk):
return
session.last_consolidated = end_idx
self.sessions.save(session)

66
nanobot/agent/personas.py Normal file
View File

@@ -0,0 +1,66 @@
"""Helpers for resolving session personas within a workspace."""
from __future__ import annotations
import re
from pathlib import Path
DEFAULT_PERSONA = "default"
PERSONAS_DIRNAME = "personas"
_VALID_PERSONA_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$")
def normalize_persona_name(name: str | None) -> str | None:
"""Normalize a user-supplied persona name."""
if not isinstance(name, str):
return None
cleaned = name.strip()
if not cleaned:
return None
if cleaned.lower() == DEFAULT_PERSONA:
return DEFAULT_PERSONA
if not _VALID_PERSONA_RE.fullmatch(cleaned):
return None
return cleaned
def personas_root(workspace: Path) -> Path:
"""Return the workspace-local persona root directory."""
return workspace / PERSONAS_DIRNAME
def list_personas(workspace: Path) -> list[str]:
"""List available personas, always including the built-in default persona."""
personas: dict[str, str] = {DEFAULT_PERSONA.lower(): DEFAULT_PERSONA}
root = personas_root(workspace)
if root.exists():
for child in root.iterdir():
if not child.is_dir():
continue
normalized = normalize_persona_name(child.name)
if normalized is None:
continue
personas.setdefault(normalized.lower(), child.name)
return sorted(personas.values(), key=lambda value: (value.lower() != DEFAULT_PERSONA, value.lower()))
def resolve_persona_name(workspace: Path, name: str | None) -> str | None:
"""Resolve a persona name to the canonical workspace directory name."""
normalized = normalize_persona_name(name)
if normalized is None:
return None
if normalized == DEFAULT_PERSONA:
return DEFAULT_PERSONA
available = {persona.lower(): persona for persona in list_personas(workspace)}
return available.get(normalized.lower())
def persona_workspace(workspace: Path, persona: str | None) -> Path:
"""Return the effective workspace root for a persona."""
resolved = resolve_persona_name(workspace, persona)
if resolved in (None, DEFAULT_PERSONA):
return workspace
return personas_root(workspace) / resolved

View File

@@ -14,6 +14,7 @@ from telegram.request import HTTPXRequest
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.agent.i18n import help_lines, normalize_language_code, telegram_command_descriptions, text
from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_media_dir
from nanobot.config.schema import TelegramConfig
@@ -158,14 +159,7 @@ class TelegramChannel(BaseChannel):
name = "telegram"
display_name = "Telegram"
# Commands registered with Telegram's command menu
BOT_COMMANDS = [
BotCommand("start", "Start the bot"),
BotCommand("new", "Start a new conversation"),
BotCommand("stop", "Stop the current task"),
BotCommand("help", "Show available commands"),
BotCommand("restart", "Restart the bot"),
]
COMMAND_NAMES = ("start", "new", "lang", "persona", "stop", "help", "restart")
def __init__(self, config: TelegramConfig, bus: MessageBus):
super().__init__(config, bus)
@@ -198,6 +192,17 @@ class TelegramChannel(BaseChannel):
return sid in allow_list or username in allow_list
@classmethod
def _build_bot_commands(cls, language: str) -> list[BotCommand]:
"""Build localized command menu entries."""
labels = telegram_command_descriptions(language)
return [BotCommand(name, labels[name]) for name in cls.COMMAND_NAMES]
@staticmethod
def _preferred_language(user) -> str:
"""Map Telegram's user language code to a supported locale."""
return normalize_language_code(getattr(user, "language_code", None)) or "en"
async def start(self) -> None:
"""Start the Telegram bot with long polling."""
if not self.config.token:
@@ -221,6 +226,8 @@ class TelegramChannel(BaseChannel):
# Add command handlers
self._app.add_handler(CommandHandler("start", self._on_start))
self._app.add_handler(CommandHandler("new", self._forward_command))
self._app.add_handler(CommandHandler("lang", self._forward_command))
self._app.add_handler(CommandHandler("persona", self._forward_command))
self._app.add_handler(CommandHandler("stop", self._forward_command))
self._app.add_handler(CommandHandler("restart", self._forward_command))
self._app.add_handler(CommandHandler("help", self._on_help))
@@ -247,7 +254,8 @@ class TelegramChannel(BaseChannel):
logger.info("Telegram bot @{} connected", bot_info.username)
try:
await self._app.bot.set_my_commands(self.BOT_COMMANDS)
await self._app.bot.set_my_commands(self._build_bot_commands("en"))
await self._app.bot.set_my_commands(self._build_bot_commands("zh"), language_code="zh-hans")
logger.debug("Telegram bot commands registered")
except Exception as e:
logger.warning("Failed to register bot commands: {}", e)
@@ -420,22 +428,15 @@ class TelegramChannel(BaseChannel):
return
user = update.effective_user
await update.message.reply_text(
f"👋 Hi {user.first_name}! I'm nanobot.\n\n"
"Send me a message and I'll respond!\n"
"Type /help to see available commands."
)
language = self._preferred_language(user)
await update.message.reply_text(text(language, "start_greeting", name=user.first_name))
async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Handle /help command, bypassing ACL so all users can access it."""
if not update.message:
if not update.message or not update.effective_user:
return
await update.message.reply_text(
"🐈 nanobot commands:\n"
"/new — Start a new conversation\n"
"/stop — Stop the current task\n"
"/help — Show available commands"
)
language = self._preferred_language(update.effective_user)
await update.message.reply_text("\n".join(help_lines(language)))
@staticmethod
def _sender_id(user) -> str:

47
nanobot/locales/en.json Normal file
View File

@@ -0,0 +1,47 @@
{
"texts": {
"current_marker": "current",
"new_session_started": "New session started.",
"memory_archival_failed_session": "Memory archival failed, session not cleared. Please try again.",
"memory_archival_failed_persona": "Memory archival failed, persona not switched. Please try again.",
"help_header": "🐈 nanobot commands:",
"cmd_new": "/new — Start a new conversation",
"cmd_lang_current": "/lang current — Show the active language",
"cmd_lang_list": "/lang list — List available languages",
"cmd_lang_set": "/lang set <en|zh> — Switch command language",
"cmd_persona_current": "/persona current — Show the active persona",
"cmd_persona_list": "/persona list — List available personas",
"cmd_persona_set": "/persona set <name> — Switch persona and start a new session",
"cmd_stop": "/stop — Stop the current task",
"cmd_restart": "/restart — Restart the bot",
"cmd_help": "/help — Show available commands",
"current_persona": "Current persona: {persona}",
"available_personas": "Available personas:\n{items}",
"unknown_persona": "Unknown persona: {name}\nAvailable personas: {personas}\nCreate one under {path} and add SOUL.md or USER.md.",
"persona_already_active": "Persona {persona} is already active.",
"switched_persona": "Switched persona to {persona}. New session started.",
"current_language": "Current language: {language_name}",
"available_languages": "Available languages:\n{items}",
"unknown_language": "Unknown language: {name}\nAvailable languages: {languages}",
"language_already_active": "Language {language_name} is already active.",
"switched_language": "Language switched to {language_name}.",
"stopped_tasks": "Stopped {count} task(s).",
"no_active_task": "No active task to stop.",
"restarting": "Restarting...",
"generic_error": "Sorry, I encountered an error.",
"start_greeting": "Hi {name}. I'm nanobot.\n\nSend me a message and I'll respond.\nType /help to see available commands."
},
"language_labels": {
"en": "English",
"zh": "Chinese"
},
"telegram_commands": {
"start": "Start the bot",
"new": "Start a new conversation",
"lang": "Switch language",
"persona": "Show or switch personas",
"stop": "Stop the current task",
"help": "Show command help",
"restart": "Restart the bot"
}
}

47
nanobot/locales/zh.json Normal file
View File

@@ -0,0 +1,47 @@
{
"texts": {
"current_marker": "当前",
"new_session_started": "已开始新的会话。",
"memory_archival_failed_session": "记忆归档失败,会话未清空,请稍后重试。",
"memory_archival_failed_persona": "记忆归档失败,人格未切换,请稍后重试。",
"help_header": "🐈 nanobot 命令:",
"cmd_new": "/new — 开启新的对话",
"cmd_lang_current": "/lang current — 查看当前语言",
"cmd_lang_list": "/lang list — 查看可用语言",
"cmd_lang_set": "/lang set <en|zh> — 切换命令语言",
"cmd_persona_current": "/persona current — 查看当前人格",
"cmd_persona_list": "/persona list — 查看可用人格",
"cmd_persona_set": "/persona set <name> — 切换人格并开始新会话",
"cmd_stop": "/stop — 停止当前任务",
"cmd_restart": "/restart — 重启机器人",
"cmd_help": "/help — 查看命令帮助",
"current_persona": "当前人格:{persona}",
"available_personas": "可用人格:\n{items}",
"unknown_persona": "未知人格:{name}\n可用人格{personas}\n请在 {path} 下创建人格目录,并添加 SOUL.md 或 USER.md。",
"persona_already_active": "人格 {persona} 已经处于启用状态。",
"switched_persona": "已切换到人格 {persona},并开始新的会话。",
"current_language": "当前语言:{language_name}",
"available_languages": "可用语言:\n{items}",
"unknown_language": "未知语言:{name}\n可用语言{languages}",
"language_already_active": "语言 {language_name} 已经处于启用状态。",
"switched_language": "已切换语言为 {language_name}。",
"stopped_tasks": "已停止 {count} 个任务。",
"no_active_task": "当前没有可停止的任务。",
"restarting": "正在重启……",
"generic_error": "抱歉,处理时遇到了错误。",
"start_greeting": "你好,{name}!我是 nanobot。\n\n给我发消息我就会回复你。\n输入 /help 查看可用命令。"
},
"language_labels": {
"en": "英语",
"zh": "中文"
},
"telegram_commands": {
"start": "启动机器人",
"new": "开启新对话",
"lang": "切换语言",
"persona": "查看或切换人格",
"stop": "停止当前任务",
"help": "查看命令帮助",
"restart": "重启机器人"
}
}