feat: 分离指令前缀与唤醒词,支持独立配置#8201
Conversation
There was a problem hiding this comment.
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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
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.
|
@sourcery-ai review |
|
/gemini review |
There was a problem hiding this comment.
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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| command_prefixes = self.ctx.astrbot_config.get("command_prefix", wake_prefixes) | |
| command_prefixes = self.ctx.astrbot_config.get("command_prefix") or wake_prefixes |
There was a problem hiding this comment.
启动时会将None替换为默认值,运行时不会出现None。
在群聊中,当唤醒词设置为
/时,其他机器人的指令(如/grok、/set)会被 AstrBot 识别为唤醒,并调用 LLM 进行回复,导致误响应。根本原因是 AstrBot 原有设计中,唤醒词(触发 LLM 对话)和指令前缀(触发插件指令)是同一个配置项,无法区分"这条消息是发给我的"和"这条消息是其他机器人的指令"。
Closes #8190 ,Related #7185,Closes #7313,closes #8037,Related #1941,closes #4518 以及?Related #1953
Modifications / 改动点
新增配置项
command_prefixwake_prefix同层)["/"]platform_settings.ignore_unknown_prefix_commandplatform_settingsFalse核心逻辑变更
astrbot/core/pipeline/waking_check/stage.pycommand_prefix配置读取,与wake_prefix分两个分支处理is_command_prefix_triggered标记command_prefix与wake_prefix不同时设置matched_wake_prefix_only标记,避免唤醒词误触发指令is_command_prefix_triggered=True且ignore_unknown_prefix_command=True时,若无真正的指令 handler(含CommandFilter/CommandGroupFilter)命中,则stop_event(),不触发 LLMmessages[0]访问前未判空的潜在问题astrbot/core/star/filter/command.py/command_group.pymatched_wake_prefix_only=True)时,跳过指令匹配,只允许 LLM 处理astrbot/core/config/default.pycommand_prefix、ignore_unknown_prefix_command默认值、misc_config_groupschema 及 WebUI metadatadashboard/src/i18n/locales/*/features/config-metadata.jsonScreenshots or Test Results / 运行截图或测试结果
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.txtandpyproject.toml./ 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到
requirements.txt和pyproject.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:
Bug Fixes:
Enhancements:
Documentation: