Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion astrbot/core/star/filter/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,18 @@ def filter(self, event: AstrMessageEvent, cfg: AstrBotConfig) -> bool:
# 检查是否以指令开头
message_str = re.sub(r"\s+", " ", event.get_message_str().strip())
ok = False
for full_cmd in self.get_complete_command_names():
# Try the longest name first so that when several complete command names
# share a prefix (e.g. an alias "show" and "show all"), the most specific
# one wins and the match is stable. get_complete_command_names() is built
# from a set, so its own order is not deterministic; sort here rather than
# there because other callers rely on [0] being the primary command name.
for full_cmd in sorted(
self.get_complete_command_names(), key=lambda name: (-len(name), name)
):
if message_str.startswith(f"{full_cmd} ") or message_str == full_cmd:
ok = True
message_str = message_str[len(full_cmd) :].strip()
break

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

With the introduction of break, the matching process now stops at the first match. This makes the iteration order of get_complete_command_names() extremely critical.

Currently, get_complete_command_names() constructs the list using list(self.alias). Since self.alias is a set, its iteration order is non-deterministic in Python due to hash randomization. If a command has overlapping aliases (e.g., one alias is a prefix of another, such as "show" and "show all"), the matched command will be non-deterministic across different runs or environments.

To ensure deterministic matching and that the most specific (longest) command/alias is matched first, the list of complete command names should be sorted by length in descending order. Since get_complete_command_names is outside the modified diff hunk, please update it in astrbot/core/star/filter/command.py as follows:

def get_complete_command_names(self):
    if self._cmpl_cmd_names is not None:
        return self._cmpl_cmd_names
    names = [
        f"{parent} {cmd}" if parent else cmd
        for cmd in [self.command_name] + list(self.alias)
        for parent in self.parent_command_names or [""]
    ]
    # Sort by length descending to ensure deterministic matching and that longer (more specific) commands match first
    self._cmpl_cmd_names = sorted(names, key=len, reverse=True)
    return self._cmpl_cmd_names

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — the break did make the candidate order decide the winner. I fixed it in ab98891 by matching longest-first in the loop (sorted(..., key=lambda name: (-len(name), name))), so the most specific of any prefix-sharing names (e.g. an alias show vs show all) always wins deterministically, independent of the set's iteration order.

I deliberately sorted in the match loop rather than in get_complete_command_names() itself: a few dashboard callers (plugin_service.py:828/862) rely on get_complete_command_names()[0] being the primary command name, which a length sort there would break. Added test_command_filter_prefers_longest_overlapping_command_name to pin it — it fails on the unsorted order and passes with the fix.

if not ok:
return False

Expand Down
48 changes: 48 additions & 0 deletions tests/test_command_filter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from types import SimpleNamespace

from astrbot.core.star.filter.command import CommandFilter, GreedyStr


def _run_filter(command_name: str, alias: set, message: str):
cmd_filter = CommandFilter(command_name=command_name, alias=set(alias))
# A single greedy parameter captures everything after the command name.
cmd_filter.handler_params = {"query": GreedyStr}
extras: dict = {}
event = SimpleNamespace(
is_at_or_wake_command=True,
get_message_str=lambda: message,
set_extra=lambda key, value: extras.__setitem__(key, value),
)
ok = cmd_filter.filter(event, None)
return ok, extras.get("parsed_params")


def test_command_filter_keeps_argument_matching_an_alias():
# Invoking a command by one name with a first argument that happens to equal
# another alias of the same command must not strip that argument a second time.
ok, params = _run_filter("search", {"find"}, "search find keyword")
assert ok
assert params == {"query": "find keyword"}


def test_command_filter_keeps_sole_argument_matching_an_alias():
ok, params = _run_filter("add", {"new"}, "add new")
assert ok
assert params == {"query": "new"}


def test_command_filter_normal_argument_unaffected():
ok, params = _run_filter("search", {"find"}, "search cat photo")
assert ok
assert params == {"query": "cat photo"}


def test_command_filter_prefers_longest_overlapping_command_name():
# When a command name and one of its aliases share a prefix ("show" vs
# "show all"), the most specific one must win regardless of the set's
# iteration order, so only the longer name is stripped and the rest is the
# argument. Without the longest-first ordering, "show" would match first and
# leak "all" into the argument.
ok, params = _run_filter("show", {"show all"}, "show all photos")
assert ok
assert params == {"query": "photos"}
Loading