Skip to content

feat: 分离指令前缀与唤醒词,支持独立配置#8201

Open
lingyun14beta wants to merge 8 commits into
AstrBotDevs:masterfrom
lingyun14beta:feat/separate-command-wake-prefix
Open

feat: 分离指令前缀与唤醒词,支持独立配置#8201
lingyun14beta wants to merge 8 commits into
AstrBotDevs:masterfrom
lingyun14beta:feat/separate-command-wake-prefix

Conversation

@lingyun14beta
Copy link
Copy Markdown
Contributor

@lingyun14beta lingyun14beta commented May 15, 2026

在群聊中,当唤醒词设置为 / 时,其他机器人的指令(如 /grok/set)会被 AstrBot 识别为唤醒,并调用 LLM 进行回复,导致误响应。

根本原因是 AstrBot 原有设计中,唤醒词(触发 LLM 对话)和指令前缀(触发插件指令)是同一个配置项,无法区分"这条消息是发给我的"和"这条消息是其他机器人的指令"。

Closes #8190 ,Related #7185Closes #7313closes #8037,Related #1941closes #4518 以及?Related #1953

Modifications / 改动点

新增配置项

配置项 层级 默认值 说明
command_prefix 顶层(与 wake_prefix 同层) ["/"] 触发插件指令的前缀,可与唤醒词独立配置
platform_settings.ignore_unknown_prefix_command platform_settings False 启用后,以指令前缀开头但不匹配任何已注册指令的消息将被静默忽略,不触发 LLM

核心逻辑变更

astrbot/core/pipeline/waking_check/stage.py

  • 新增 command_prefix 配置读取,与 wake_prefix 分两个分支处理
  • 指令前缀分支:匹配后进入指令匹配流程,设置 is_command_prefix_triggered 标记
  • 唤醒词分支:匹配后触发 LLM,command_prefixwake_prefix 不同时设置 matched_wake_prefix_only 标记,避免唤醒词误触发指令
  • 新增守卫逻辑:is_command_prefix_triggered=Trueignore_unknown_prefix_command=True 时,若无真正的指令 handler(含 CommandFilter / CommandGroupFilter)命中,则 stop_event(),不触发 LLM
  • 修复 messages[0] 访问前未判空的潜在问题

astrbot/core/star/filter/command.py / command_group.py

  • 消息仅通过唤醒词触发(matched_wake_prefix_only=True)时,跳过指令匹配,只允许 LLM 处理

astrbot/core/config/default.py

  • 新增 command_prefixignore_unknown_prefix_command 默认值、misc_config_group schema 及 WebUI metadata

dashboard/src/i18n/locales/*/features/config-metadata.json

  • 新增 zh-CN / en-US / ru-RU 三语言翻译

  • This is NOT a breaking change. / 这不是一个破坏性变更。

Screenshots or Test Results / 运行截图或测试结果

image

Checklist / 检查清单

  • 😊 If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
    / 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。

  • 👀 My changes have been well-tested, and "Verification Steps" and "Screenshots" have been provided above.
    / 我的更改经过了良好的测试,并已在上方提供了“验证步骤”和“运行截图”

  • 🤓 I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in requirements.txt and pyproject.toml.
    / 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到 requirements.txtpyproject.toml 文件相应位置。

  • 😮 My changes do not introduce malicious code.
    / 我的更改没有引入恶意代码。

Summary by Sourcery

Separate wake word handling from command prefix handling to avoid mis-triggering LLM responses on other bots' commands and add configuration to control how unknown prefixed commands are treated.

New Features:

  • Introduce a configurable command_prefix separate from wake_prefix to independently trigger plugin commands without waking the LLM.
  • Add a platform_settings.ignore_unknown_prefix_command option to silently ignore messages starting with the command prefix that do not match any registered command handlers.

Bug Fixes:

  • Prevent potential errors by guarding access to the first message segment before use in the waking check pipeline.

Enhancements:

  • Update waking check logic so that command-prefixed messages and wake-word messages are processed through distinct branches, allowing wake-only messages to bypass command matching and be handled solely by the LLM.
  • Refine command and command group filters to skip command matching when a message was triggered only by a wake word in configurations where wake and command prefixes differ.

Documentation:

  • Expose the new command_prefix and ignore_unknown_prefix_command options in the configuration metadata with localized descriptions and hints in the dashboard.

@auto-assign auto-assign Bot requested review from LIghtJUNction and anka-afk May 15, 2026 19:40
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. area:core The bug / feature is about astrbot's core, backend labels May 15, 2026
Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/pipeline/waking_check/stage.py" line_range="285-287" />
<code_context>
+        )
+        if is_command_prefix_triggered and ignore_unknown:
+            # 检查是否有真正的指令 handler 被激活(含 CommandFilter 或 CommandGroupFilter)
+            has_command_handler = any(
+                any(
+                    isinstance(f, CommandFilter | CommandGroupFilter)
+                    for f in handler.event_filters
+                )
</code_context>
<issue_to_address>
**issue (bug_risk):** The `isinstance` check with `CommandFilter | CommandGroupFilter` is incorrect and will never match.

In `isinstance`, the second argument must be a type or a tuple of types. `CommandFilter | CommandGroupFilter` creates a `types.UnionType`, so `isinstance(f, CommandFilter | CommandGroupFilter)` will always return `False`, making `has_command_handler` always `False` and causing all prefix-triggered messages to be treated as unknown and ignored when `ignore_unknown_prefix_command` is enabled.

Use a tuple instead:

```python
has_command_handler = any(
    any(
        isinstance(f, (CommandFilter, CommandGroupFilter))
        for f in handler.event_filters
    )
    for handler in activated_handlers
)
```
</issue_to_address>

### Comment 2
<location path="astrbot/core/pipeline/waking_check/stage.py" line_range="81" />
<code_context>
+        # 以下配置在 process() 中每次读取以支持热更新,此处仅作初始化说明
+        # wake_prefix, command_prefix, ignore_unknown_prefix_command 通过 self.ctx.astrbot_config 热读取

     async def process(
         self,
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the shared prefix-matching logic and command-handler detection into helper functions and using a single match result object to simplify the `process()` flow.

You can keep all the new behavior while reducing complexity by extracting the duplicated prefix logic and the “is command handler” detection into small helpers, and by collapsing the scattered flags into a single match result.

### 1) Extract shared prefix handling

Both `command_prefixes` and `wake_prefixes` branches do the same checks (startswith, At-guard, strip prefix, set flags). You can unify them with a small helper that returns a structured result instead of scattering booleans:

```python
from dataclasses import dataclass
from enum import Enum, auto

class PrefixKind(Enum):
    NONE = auto()
    COMMAND = auto()
    WAKE = auto()

@dataclass
class PrefixMatchResult:
    kind: PrefixKind = PrefixKind.NONE
    prefix: str | None = None

def _match_prefix(
    event: AstrMessageEvent,
    prefixes: list[str],
    kind: PrefixKind,
) -> PrefixMatchResult:
    messages = event.get_messages()
    for p in prefixes:
        if not p or not event.message_str.startswith(p):
            continue
        if (
            messages
            and not event.is_private_chat()
            and isinstance(messages[0], At)
            and str(messages[0].qq) != str(event.get_self_id())
            and str(messages[0].qq) != "all"
        ):
            # 群聊中首个 At 不是机器人/全体,直接视为不匹配
            return PrefixMatchResult()
        event.message_str = event.message_str[len(p) :].strip()
        event.is_wake = True
        event.is_at_or_wake_command = True
        return PrefixMatchResult(kind=kind, prefix=p)
    return PrefixMatchResult()
```

Then `process()` can become much easier to follow:

```python
wake_prefixes = self.ctx.astrbot_config["wake_prefix"]
command_prefixes = self.ctx.astrbot_config.get("command_prefix", wake_prefixes)

cmd_match = _match_prefix(event, command_prefixes, PrefixKind.COMMAND)
wake_match = PrefixMatchResult()
if cmd_match.kind is PrefixKind.NONE:
    wake_match = _match_prefix(event, wake_prefixes, PrefixKind.WAKE)

match = cmd_match if cmd_match.kind is not PrefixKind.NONE else wake_match
is_wake = match.kind is not PrefixKind.NONE
is_command_prefix_triggered = match.kind is PrefixKind.COMMAND
```

This removes the duplicated loops and centralizes the At-guard + flag setting in one place.

### 2) Centralize `matched_wake_prefix_only` decision

Once `match` encapsulates the trigger kind, you can express `matched_wake_prefix_only` in one place instead of embedding configuration checks inside the wake loop:

```python
if match.kind is PrefixKind.WAKE:
    # 仅当 command_prefix 配置与 wake_prefix 有差异时,认为是纯唤醒
    if command_prefixes and set(command_prefixes) != set(wake_prefixes):
        event.set_extra("matched_wake_prefix_only", True)
```

This keeps the existing behavior but makes it clear that the flag is an interpretation of the match result, not a side-effect of a specific loop.

### 3) Extract “is command handler” logic

The nested `any(any(...))` with `isinstance` checks can be hidden behind a tiny helper to make the intent clear and avoid re-encoding the same logic elsewhere:

```python
def _is_command_handler(handler) -> bool:
    return any(
        isinstance(f, (CommandFilter, CommandGroupFilter))
        for f in handler.event_filters
    )
```

Then the ignore-unknown block becomes simpler and easier to reuse:

```python
ignore_unknown = self.ctx.astrbot_config.get("platform_settings", {}).get(
    "ignore_unknown_prefix_command", False
)

if is_command_prefix_triggered and ignore_unknown:
    has_command_handler = any(_is_command_handler(h) for h in activated_handlers)
    if not has_command_handler:
        event.stop_event()
        return
```

These changes keep all current semantics (including the difference between command vs wake prefixes and `ignore_unknown_prefix_command`) but significantly reduce the mental load when reading `process()`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/pipeline/waking_check/stage.py Outdated
Comment thread astrbot/core/pipeline/waking_check/stage.py
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a distinction between command prefixes and wake words, allowing users to configure them separately. It adds a new configuration option, ignore_unknown_prefix_command, to silently ignore messages starting with a command prefix that do not match any registered commands, preventing unnecessary LLM triggers. Localization files were updated to include descriptions for these new settings. Feedback was provided to refactor the WakingCheckStage logic by extracting duplicate At message checks and pre-calculating prefix differences to improve code readability and performance.

Comment thread astrbot/core/pipeline/waking_check/stage.py
@lingyun14beta
Copy link
Copy Markdown
Contributor Author

@sourcery-ai review

@lingyun14beta
Copy link
Copy Markdown
Contributor Author

/gemini review

Copy link
Copy Markdown
Contributor

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 2 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="astrbot/core/pipeline/waking_check/stage.py" line_range="279-288" />
<code_context>
+        # 且 ignore_unknown_prefix_command=True,则静默忽略,不触发 LLM。
+        # 默认 False(保持原版行为);设为 True 后可避免误响应其他机器人的指令(如 /grok)。
+        # 注意:部分 handler(如 on_message)没有 CommandFilter,不算指令 handler。
+        ignore_unknown = self.ctx.astrbot_config.get("platform_settings", {}).get(
+            "ignore_unknown_prefix_command", False
+        )
+        if is_command_prefix_triggered and ignore_unknown:
+            # 检查是否有真正的指令 handler 被激活(含 CommandFilter 或 CommandGroupFilter)
+            has_command_handler = any(
+                any(
+                    isinstance(f, (CommandFilter, CommandGroupFilter))
+                    for f in handler.event_filters
+                )
+                for handler in activated_handlers
+            )
+            if not has_command_handler:
+                event.stop_event()
+                return
+
</code_context>
<issue_to_address>
**issue:** Ignoring unknown prefix commands also suppresses non-command handlers, which may not match the option’s intent.

In this branch, `event.stop_event()` is called whenever a command prefix is detected but no `CommandFilter`/`CommandGroupFilter` handler is active. That also blocks generic handlers (like `on_message` without command filters), even though the comment says they “don’t count as command handlers,” and the option text only mentions avoiding LLM/command handling.

If the intent is to only skip LLM/command processing while still allowing non-command handlers, consider either:
- Stopping the event only when `activated_handlers` is empty, or
- Applying this check only in the command/LLM dispatch path.

It’s worth confirming whether suppressing non-command handlers is intentional; if not, narrowing the condition would better match the option’s stated behavior.
</issue_to_address>

### Comment 2
<location path="astrbot/core/pipeline/waking_check/stage.py" line_range="114" />
<code_context>
-                ):
-                    # 如果是群聊,且第一个消息段是 At 消息,但不是 At 机器人或 At 全体成员,则不唤醒
+
+        # 提取公共的 At 检查逻辑:群聊中首个消息段是 At 他人(非机器人/全体)时不唤醒
+        is_at_others = (
+            messages
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the shared prefix-handling and unknown-command ignoring logic into small helper functions to simplify `process()` and remove duplication while preserving behavior.

You can reduce the added complexity by extracting the duplicated prefix handling and the late `ignore_unknown_prefix_command` check into small helpers, while keeping behavior exactly the same.

### 1. Extract common prefix-matching logic

Right now you have two very similar loops for `command_prefixes` and `wake_prefixes`, plus the inline `is_at_others` / `is_different_prefixes` flags.

You can hide most of that in a small helper that:

* checks `is_at_others`
* matches any prefix
* trims `event.message_str`
* sets wake-related flags / extras
* reports what kind of prefix matched

Example (focused, not full code):

```python
# near the class
from enum import Enum, auto

class PrefixMatchKind(Enum):
    NONE = auto()
    COMMAND = auto()
    WAKE = auto()

def _is_at_others(event: AstrMessageEvent, messages: list) -> bool:
    return (
        messages
        and not event.is_private_chat()
        and isinstance(messages[0], At)
        and str(messages[0].qq) != str(event.get_self_id())
        and str(messages[0].qq) != "all"
    )

def _consume_prefix(
    event: AstrMessageEvent,
    prefixes: list[str],
    *,
    kind: PrefixMatchKind,
    is_at_others: bool,
    mark_wake_only: bool = False,
) -> PrefixMatchKind:
    if is_at_others:
        return PrefixMatchKind.NONE

    for prefix in prefixes:
        if prefix and event.message_str.startswith(prefix):
            event.message_str = event.message_str[len(prefix):].strip()
            event.is_wake = True
            event.is_at_or_wake_command = True
            if mark_wake_only:
                event.set_extra("matched_wake_prefix_only", True)
            return kind

    return PrefixMatchKind.NONE
```

Then `process()` becomes more linear and avoids duplicated loops:

```python
messages = event.get_messages()
wake_prefixes = self.ctx.astrbot_config["wake_prefix"]
command_prefixes = self.ctx.astrbot_config.get("command_prefix", wake_prefixes)

is_at_others = _is_at_others(event, messages)
is_different_prefixes = bool(command_prefixes and set(command_prefixes) != set(wake_prefixes))

match_kind = PrefixMatchKind.NONE

# 先尝试 command_prefix
if command_prefixes:
    match_kind = _consume_prefix(
        event,
        command_prefixes,
        kind=PrefixMatchKind.COMMAND,
        is_at_others=is_at_others,
        mark_wake_only=False,
    )

# 再尝试 wake_prefix(仅在尚未匹配到指令前缀时)
if match_kind is PrefixMatchKind.NONE:
    match_kind = _consume_prefix(
        event,
        wake_prefixes,
        kind=PrefixMatchKind.WAKE,
        is_at_others=is_at_others,
        mark_wake_only=is_different_prefixes,
    )

is_wake = match_kind is not PrefixMatchKind.NONE
is_command_prefix_triggered = match_kind is PrefixMatchKind.COMMAND
```

This:

* removes the duplicated loops
* centralizes flag manipulation (`event.is_wake`, `event.is_at_or_wake_command`, `matched_wake_prefix_only`)
* keeps `is_wake` / `is_command_prefix_triggered` semantics unchanged

### 2. Encapsulate `ignore_unknown_prefix_command` decision

The tail block that checks `ignore_unknown_prefix_command` can also be encapsulated, so `process()` just reads as “if we should ignore, stop the event”:

```python
from typing import Iterable

def _has_command_handler(handlers: Iterable) -> bool:
    return any(
        any(isinstance(f, (CommandFilter, CommandGroupFilter)) for f in handler.event_filters)
        for handler in handlers
    )

def _should_ignore_unknown_command(
    self,
    is_command_prefix_triggered: bool,
    activated_handlers: list,
) -> bool:
    if not is_command_prefix_triggered:
        return False

    ignore_unknown = self.ctx.astrbot_config.get("platform_settings", {}).get(
        "ignore_unknown_prefix_command", False
    )
    if not ignore_unknown:
        return False

    return not _has_command_handler(activated_handlers)
```

Usage in `process()`:

```python
event.set_extra("activated_handlers", activated_handlers)
event.set_extra("handlers_parsed_params", handlers_parsed_params)

if self._should_ignore_unknown_command(is_command_prefix_triggered, activated_handlers):
    event.stop_event()
    return

if not is_wake:
    event.stop_event()
```

This keeps the hot-reload behavior (config is still read in `process()`), but removes the nested `any(any(...))` and ties the decision to a single, clearly named helper.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/pipeline/waking_check/stage.py
Comment thread astrbot/core/pipeline/waking_check/stage.py
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request separates command_prefix for plugin commands from wake_prefix for LLM interactions and adds an ignore_unknown_prefix_command setting to prevent LLM triggers on unrecognized commands. These changes span configuration defaults, the waking check pipeline, command filters, and localization files. Feedback focused on making the command_prefix retrieval more defensive by using a logical or to ensure a fallback to wake_prefix if the configuration value is null or empty, preventing potential type errors.

# command_prefix 用于匹配指令前缀,与唤醒词(wake_prefix)分开配置。
# 启动时 check_config_integrity 保证 command_prefix 已有默认值,不会为 None。
wake_prefixes = self.ctx.astrbot_config["wake_prefix"]
command_prefixes = self.ctx.astrbot_config.get("command_prefix", wake_prefixes)
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.

medium

The retrieval of command_prefix should be more defensive. If the configuration key exists in the user's config file but is set to null, the current get("command_prefix", wake_prefixes) call will return None instead of the default wake_prefixes. This would cause a TypeError later when the code attempts to iterate over command_prefixes or convert it to a set. Using the or operator ensures that any falsy value (including None or an empty list) correctly falls back to wake_prefixes, which also aligns with the "fallback to wake word" behavior mentioned in the documentation.

Suggested change
command_prefixes = self.ctx.astrbot_config.get("command_prefix", wake_prefixes)
command_prefixes = self.ctx.astrbot_config.get("command_prefix") or wake_prefixes

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.

启动时会将None替换为默认值,运行时不会出现None。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core The bug / feature is about astrbot's core, backend size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

1 participant