mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-01 07:15:52 +00:00
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.
99 lines
3.2 KiB
Python
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
|