nanobot/nanobot/command/router.py
chengyongru d4e34f8c67 fix(commands): intercept non-priority commands during active turn
Non-priority slash commands (e.g. /new, /help, /dream-log) arriving
while a session has an active LLM turn were silently queued into the
pending injection buffer and later injected as raw user messages into
the LLM conversation. This caused the model to respond to "/new" as
plain text instead of executing the command.

Root cause: the run() loop only checked priority commands (/stop,
/restart, /status) before routing messages to the pending queue. All
other command tiers (exact, prefix) bypassed command dispatch entirely.

Changes:
- Add CommandRouter.is_dispatchable_command() to match exact/prefix
  tiers, mirroring the existing is_priority() pattern.
- In run(), intercept dispatchable commands before pending queue
  insertion and dispatch them directly via _dispatch_command_inline().
- Extract _cancel_active_tasks() from cmd_stop for reuse; cmd_new now
  cancels active tasks before clearing the session to prevent shared
  mutable state corruption from concurrent asyncio coroutines.
- Update /new semantics: stops active task first, then clears session.
- Update documentation in help text, docs, and Discord command list.
2026-04-21 21:50:37 +08:00

99 lines
3.2 KiB
Python

"""Minimal command routing table for slash commands."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.session.manager import Session
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
@dataclass
class CommandContext:
"""Everything a command handler needs to produce a response."""
msg: InboundMessage
session: Session | None
key: str
raw: str
args: str = ""
loop: Any = None
class CommandRouter:
"""Pure dict-based command dispatch.
Three tiers checked in order:
1. *priority* — exact-match commands handled before the dispatch lock
(e.g. /stop, /restart).
2. *exact* — exact-match commands handled inside the dispatch lock.
3. *prefix* — longest-prefix-first match (e.g. "/team ").
4. *interceptors* — fallback predicates (e.g. team-mode active check).
"""
def __init__(self) -> None:
self._priority: dict[str, Handler] = {}
self._exact: dict[str, Handler] = {}
self._prefix: list[tuple[str, Handler]] = []
self._interceptors: list[Handler] = []
def priority(self, cmd: str, handler: Handler) -> None:
self._priority[cmd] = handler
def exact(self, cmd: str, handler: Handler) -> None:
self._exact[cmd] = handler
def prefix(self, pfx: str, handler: Handler) -> None:
self._prefix.append((pfx, handler))
self._prefix.sort(key=lambda p: len(p[0]), reverse=True)
def intercept(self, handler: Handler) -> None:
self._interceptors.append(handler)
def is_priority(self, text: str) -> bool:
return text.strip().lower() in self._priority
def is_dispatchable_command(self, text: str) -> bool:
"""Check whether *text* matches any non-priority command tier (exact or prefix).
Does NOT check priority or interceptor tiers.
If this returns True, ``dispatch()`` is guaranteed to match a handler.
"""
cmd = text.strip().lower()
if cmd in self._exact:
return True
for pfx, _ in self._prefix:
if cmd.startswith(pfx):
return True
return False
async def dispatch_priority(self, ctx: CommandContext) -> OutboundMessage | None:
"""Dispatch a priority command. Called from run() without the lock."""
handler = self._priority.get(ctx.raw.lower())
if handler:
return await handler(ctx)
return None
async def dispatch(self, ctx: CommandContext) -> OutboundMessage | None:
"""Try exact, prefix, then interceptors. Returns None if unhandled."""
cmd = ctx.raw.lower()
if handler := self._exact.get(cmd):
return await handler(ctx)
for pfx, handler in self._prefix:
if cmd.startswith(pfx):
ctx.args = ctx.raw[len(pfx):]
return await handler(ctx)
for interceptor in self._interceptors:
result = await interceptor(ctx)
if result is not None:
return result
return None