diff --git a/.agents/skills/archify/SKILL.md b/.agents/skills/archify/SKILL.md index 95d1fd7420..de80c50890 100644 --- a/.agents/skills/archify/SKILL.md +++ b/.agents/skills/archify/SKILL.md @@ -75,7 +75,8 @@ Suggested maps (inspect code; do not copy example facts): | `lifecycle` | Session, conversation, or agent-run states | Group wake is explicit (`llm_access.group`, `llm_access.reply_to_bot`, -continuation). Do not draw `platform_settings.group_wake_policy`. +continuation). Mentions are message-chain markers, not a wake policy. +Do not draw `platform_settings.group_wake_policy`. ## Verify diff --git a/.agents/skills/audit-product/REFERENCE.md b/.agents/skills/audit-product/REFERENCE.md index a8bf3b37d9..ba2ee08df2 100644 --- a/.agents/skills/audit-product/REFERENCE.md +++ b/.agents/skills/audit-product/REFERENCE.md @@ -180,7 +180,7 @@ Violations are findings, not style nits: - No legacy shims; no Python 3.10–3.13 branches - Import boundaries in `tests/unit/test_import_boundaries.py` -- Group wake is explicit (`llm_access.group`, `llm_access.reply_to_bot`) +- Group wake is explicit (`llm_access.group`, `llm_access.reply_to_bot`, continuation); mention is not a wake policy - Command identity is `command_id`; no fossil short-name lookup - Dashboard bind defaults to `127.0.0.1`; MCP private-network default deny - User-facing agent failures stay generic; redact secrets diff --git a/AGENTS.md b/AGENTS.md index 1267144fa1..6f0c7981d9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -243,10 +243,10 @@ The scheduler supports async stages and async-generator onion middleware. Preserve stage ordering, stop-propagation, and cancellation semantics. Group wake behavior is explicit. `llm_access.group`, `llm_access.reply_to_bot`, -and continuation state control whether mentioning or replying to the bot wakes -a group message, and `WakingCheckStage` records the selected `wake_reasons` on -the event. Do not restore `platform_settings.group_wake_policy` or implicit -mention/reply wakeups. Built-in command availability is stored +and continuation state control group LLM admission. Mentions are message-chain +markers, not a wake policy. `WakingCheckStage` records the selected +`wake_reasons` on the event. Do not restore `platform_settings.group_wake_policy` +or implicit mention/reply wakeups. Built-in command availability is stored per handler in the command database; the removed `disable_builtin_commands` field is not migrated or read by runtime code and must not become a pipeline switch again. Command identity is `command_id` diff --git a/astrbot/builtin_stars/astrbot/main.py b/astrbot/builtin_stars/astrbot/main.py index 29bad58e83..cd9b036857 100644 --- a/astrbot/builtin_stars/astrbot/main.py +++ b/astrbot/builtin_stars/astrbot/main.py @@ -36,7 +36,7 @@ async def handle_session_control_agent(self, event: AstrMessageEvent) -> None: @filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1) async def handle_empty_mention(self, event: AstrMessageEvent): - """处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。""" + """Wait for the next message when the user sent only a command prefix.""" try: messages = event.get_messages() cfg = self.context.config.get(umo=event.unified_msg_origin) @@ -44,18 +44,24 @@ async def handle_empty_mention(self, event: AstrMessageEvent): command_prefixes = cfg.get("command_prefixes", []) if len(messages) != 1: return + if not p_settings.get("empty_mention_waiting", True): + return - is_empty_mention = ( - isinstance(messages[0], Comp.Mention) - and str(messages[0].target) == str(event.get_self_id()) - and p_settings.get("empty_mention_waiting", True) - ) is_command_prefix_only = ( isinstance(messages[0], Comp.Plain) and messages[0].text.strip() in command_prefixes ) + if not is_command_prefix_only: + return - if not (is_empty_mention or is_command_prefix_only): + llm_access = cfg.get("llm_access") or {} + mode = llm_access.get( + "private" if event.is_private_chat() else "group", + "prefix", + ) + if mode not in {"open", "prefix", "off"}: + mode = "prefix" + if mode == "off": return if p_settings.get("empty_mention_waiting_need_reply", True): @@ -78,7 +84,7 @@ async def handle_empty_mention(self, event: AstrMessageEvent): yield event.request_llm( prompt=( - "注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。" + "注意,你正在社交媒体上中与用户进行聊天,用户只发送了指令前缀,尚未输入内容,他可能会在接下来一条发送他想发送的内容。" "你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。" "请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西" ), @@ -91,17 +97,14 @@ async def handle_empty_mention(self, event: AstrMessageEvent): logger.error(f"LLM response failed: {e!s}") yield event.plain_result("想要问什么呢?😄") - async def empty_mention_waiter( + async def prefix_only_waiter( controller, event: AstrMessageEvent, ) -> None: if not event.message_str or not event.message_str.strip(): return - event.message_obj.message.insert( - 0, - Comp.Mention(target=event.get_self_id(), name=event.get_self_id()), - ) new_event = copy.copy(event) + new_event.set_extra("explicit_surface", True) self.context.messages.submit(new_event) event.stop_event() controller.stop() @@ -109,7 +112,7 @@ async def empty_mention_waiter( try: await self.context.messages.wait_for( event, - empty_mention_waiter, + prefix_only_waiter, timeout_seconds=60, ) except TimeoutError: diff --git a/astrbot/core/config/astrbot_config.py b/astrbot/core/config/astrbot_config.py index 3af94654c8..3bc15983ee 100644 --- a/astrbot/core/config/astrbot_config.py +++ b/astrbot/core/config/astrbot_config.py @@ -56,7 +56,6 @@ def __init__( # 调用父类的 __setattr__ 方法,防止保存配置时将此属性写入配置文件 object.__setattr__(self, "config_path", config_path) - object.__setattr__(self, "default_config", default_config) object.__setattr__(self, "schema", schema) object.__setattr__(self, "_save_state_lock", threading.Lock()) object.__setattr__(self, "_save_commit_lock", threading.Lock()) @@ -64,6 +63,7 @@ def __init__( object.__setattr__(self, "_save_committed_revision", 0) default_config = self._resolve_default_config(default_config, schema) + object.__setattr__(self, "default_config", default_config) self._ensure_config_file(default_config) conf = self._load_config_dict(config_path) dashboard_conf = conf.get("dashboard") diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 607688f133..79582964e8 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -269,7 +269,7 @@ "command_prefixes": ["/"], "llm_access": { "prefixes": ["/"], - "private": "open", + "private": "prefix", "group": "prefix", "reply_to_bot": False, }, @@ -1087,7 +1087,7 @@ }, "empty_mention_waiting": { "type": "bool", - "hint": "启用后,当消息内容只有 @ 机器人时,会触发等待,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。", + "hint": "启用后,当消息内容只有指令前缀(例如单独一个 /)时,会等待该用户 60 秒内的下一条消息,并把它当作显式对准内置 AI。单独的前缀不会当成空提示词发给模型。空 @ 不会触发等待。llm_access 为 off 时不等待、不请求模型。", }, "empty_mention_waiting_need_reply": { "type": "bool", @@ -1099,7 +1099,8 @@ }, "ignore_at_all": { "type": "bool", - "hint": "启用后,机器人会忽略 @ 全体成员 的消息事件。", + "invisible": True, + "hint": "保留字段。MentionAll 只是消息链标记,不会单独放行或拦截内置 LLM。", }, "segmented_reply": { "type": "object", @@ -3124,8 +3125,6 @@ "options": [ "open", "prefix", - "mention", - "prefix_or_mention", "off", ], }, @@ -4227,8 +4226,6 @@ "options": [ "open", "prefix", - "mention", - "prefix_or_mention", "off", ], }, @@ -4269,7 +4266,7 @@ "type": "int", }, "platform_settings.empty_mention_waiting": { - "description": "只 @ 机器人是否触发等待", + "description": "只打指令前缀是否触发等待", "type": "bool", }, }, @@ -4399,6 +4396,7 @@ "platform_settings.ignore_at_all": { "description": "是否忽略 @ 全体成员事件", "type": "bool", + "invisible": True, }, "platform_settings.no_permission_reply": { "description": "用户权限不足时是否回复", diff --git a/astrbot/core/cron/events.py b/astrbot/core/cron/events.py index c556fbf521..28a3801037 100644 --- a/astrbot/core/cron/events.py +++ b/astrbot/core/cron/events.py @@ -48,7 +48,7 @@ def __init__( # Ensure we use the original session for sending messages self.session = session self.context_obj = context - self.set_extra("adapter_preconfigured", True) + self.set_extra("explicit_surface", True) if extras: self._extras.update(extras) diff --git a/astrbot/core/pipeline/turn_router.py b/astrbot/core/pipeline/turn_router.py index 98292eada6..06866d3413 100644 --- a/astrbot/core/pipeline/turn_router.py +++ b/astrbot/core/pipeline/turn_router.py @@ -9,11 +9,11 @@ CommandResolution, CommandResolutionKind, ) -from astrbot.core.message.components import Mention, MentionAll, Reply +from astrbot.core.message.components import Mention, Reply RouteKind = Literal["ordinary", "passthrough", "turn_flush"] PrivateAccess = Literal["open", "prefix", "off"] -GroupAccess = Literal["open", "prefix", "mention", "prefix_or_mention", "off"] +GroupAccess = Literal["open", "prefix", "off"] INBOUND_FLUSH_KEYS = ("turn_flush", "turn_continuation") MANAGER_FLUSH_TOKEN = "_turn_flush_token" @@ -37,7 +37,7 @@ class LlmAccess: """LLM access policy for one configuration profile.""" prefixes: tuple[str, ...] = ("/",) - private: PrivateAccess = "open" + private: PrivateAccess = "prefix" group: GroupAccess = "prefix" reply_to_bot: bool = False @@ -56,8 +56,7 @@ class TurnRouteInput: is_notice_or_request: bool = False has_open_window: bool = False is_manager_flush: bool = False - adapter_preconfigured: bool = False - ignore_at_all: bool = False + explicit_surface: bool = False @dataclass(frozen=True, slots=True) @@ -126,11 +125,11 @@ def llm_access_from_config(config: dict) -> LlmAccess: prefixes = tuple( str(item) for item in raw.get("prefixes", ["/"]) if str(item).strip() ) - private = raw.get("private", "open") + private = raw.get("private", "prefix") group = raw.get("group", "prefix") if private not in {"open", "prefix", "off"}: - private = "open" - if group not in {"open", "prefix", "mention", "prefix_or_mention", "off"}: + private = "prefix" + if group not in {"open", "prefix", "off"}: group = "prefix" return LlmAccess( prefixes=prefixes or ("/",), @@ -231,16 +230,18 @@ def route_turn(inp: TurnRouteInput) -> TurnRouteResult: resolution, ) - if inp.adapter_preconfigured: - llm_text, reasons = _llm_payload(inp, blocked_by_other_mention) - return TurnRouteResult( - False, - True, - "ordinary", - frozenset({"adapter_preconfigured", *reasons}), - llm_text, - False, - ) + if inp.explicit_surface: + mode = inp.llm_access.private if inp.is_private else inp.llm_access.group + if mode != "off": + llm_text, reasons = _llm_payload(inp, blocked_by_other_mention) + return TurnRouteResult( + False, + True, + "ordinary", + frozenset({"explicit_surface", *reasons}), + llm_text, + False, + ) llm_ok, reasons = _llm_gate(inp, blocked_by_other_mention) if llm_ok: @@ -287,33 +288,37 @@ def _first_mention_is_other(inp: TurnRouteInput) -> bool: return str(first.target) != str(inp.self_id) +def _llm_prefix_has_payload( + inp: TurnRouteInput, blocked_by_other_mention: bool +) -> bool: + if blocked_by_other_mention: + return False + text = inp.message_str.strip(" \t") + prefix = longest_prefix_match(text, inp.llm_access.prefixes) + if prefix is None: + return False + return bool(text[len(prefix) :].strip(" \t")) + + def _llm_gate( inp: TurnRouteInput, blocked_by_other_mention: bool ) -> tuple[bool, set[str]]: if inp.has_open_window: return True, {"turn_continuation"} - mentioned_bot, mentioned_all, reply_to_bot = _mention_flags(inp) if inp.is_private: mode = inp.llm_access.private if mode == "open": return True, {"llm_open"} if mode == "off": return False, set() - if blocked_by_other_mention: - return False, set() - if longest_prefix_match(inp.message_str.strip(" \t"), inp.llm_access.prefixes): + if _llm_prefix_has_payload(inp, False): return True, {"llm_prefix"} return False, set() reasons: set[str] = set() base = False mode = inp.llm_access.group - prefix_hit = False - if not blocked_by_other_mention: - prefix_hit = ( - longest_prefix_match(inp.message_str.strip(" \t"), inp.llm_access.prefixes) - is not None - ) + prefix_hit = _llm_prefix_has_payload(inp, blocked_by_other_mention) if mode == "open": base = True reasons.add("llm_open") @@ -321,26 +326,9 @@ def _llm_gate( base = prefix_hit if prefix_hit: reasons.add("llm_prefix") - elif mode == "mention": - base = mentioned_bot or mentioned_all - if mentioned_bot: - reasons.add("mention_bot") - if mentioned_all: - reasons.add("mention_all") - elif mode == "prefix_or_mention": - base = prefix_hit or mentioned_bot or mentioned_all - if prefix_hit: - reasons.add("llm_prefix") - if mentioned_bot: - reasons.add("mention_bot") - if mentioned_all: - reasons.add("mention_all") - if inp.llm_access.reply_to_bot and reply_to_bot: + if inp.llm_access.reply_to_bot and _reply_to_bot(inp): base = True reasons.add("reply_to_bot") - if mode != "off" and mentioned_all: - base = True - reasons.add("mention_all") return base, reasons @@ -353,20 +341,13 @@ def _llm_payload( prefix = longest_prefix_match(text, inp.llm_access.prefixes) if prefix is None: return text, set() - return text[len(prefix) :].strip(" \t"), {"llm_prefix"} if prefix else set() + return text[len(prefix) :].strip(" \t"), {"llm_prefix"} -def _mention_flags(inp: TurnRouteInput) -> tuple[bool, bool, bool]: - mentioned_bot = False - mentioned_all = False - reply_to_bot = False +def _reply_to_bot(inp: TurnRouteInput) -> bool: for message in inp.messages: - if isinstance(message, Mention) and str(message.target) == str(inp.self_id): - mentioned_bot = True - if isinstance(message, MentionAll) and not inp.ignore_at_all: - mentioned_all = True if isinstance(message, Reply) and str( getattr(message, "sender_id", "") or "" ) == str(inp.self_id): - reply_to_bot = True - return mentioned_bot, mentioned_all, reply_to_bot + return True + return False diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index e7c1a1a82a..1520906b19 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -58,13 +58,9 @@ class WakeReason(Enum): - PREFIX = "prefix" COMMAND = "command" - MENTION_BOT = "mention_bot" - MENTION_ALL = "mention_all" REPLY_TO_BOT = "reply_to_bot" - PRIVATE_DEFAULT = "private_default" - ADAPTER_PRECONFIGURED = "adapter_preconfigured" + EXPLICIT_SURFACE = "explicit_surface" PLUGIN_HANDLER = "plugin_handler" LLM_PREFIX = "llm_prefix" LLM_OPEN = "llm_open" @@ -110,13 +106,12 @@ def _auth_message_type(event: AstrMessageEvent) -> str | None: class WakingCheckStage(Stage): - """检查是否需要唤醒。唤醒机器人有如下几点条件: + """Decide command, LLM, passthrough, or drop for one inbound event. - 1. 机器人被 @ 了 - 2. 机器人的消息被提到了 - 3. 以配置的 LLM 前缀开头,并且消息没有以 Mention 消息段开头 - 4. 插件(Star)的 handler filter 通过 - 5. 私聊消息的唤醒由当前平台和会话策略决定,不读取旧管理员配置 + Built-in LLM admission comes from command match, LLM prefix, continuation, + explicit ``reply_to_bot``, or extras ``explicit_surface``. Mentions stay on + the message chain and are not an LLM gate. Plugin handlers can still + activate after the LLM is dropped. """ async def initialize(self, ctx: PipelineContext) -> None: @@ -138,10 +133,6 @@ async def initialize(self, ctx: PipelineContext) -> None: "ignore_bot_self_message", False, ) - self.ignore_at_all = self.ctx.astrbot_config["platform_settings"].get( - "ignore_at_all", - False, - ) platform_settings = self.ctx.astrbot_config.get("platform_settings", {}) self.unique_session = platform_settings.get("unique_session", False) self.command_prefixes = command_prefixes_from_config(self.ctx.astrbot_config) @@ -211,8 +202,7 @@ async def process( has_open_window=has_open_window or bool(event.get_extra("turn_continuation")), is_manager_flush=manager_flush, - adapter_preconfigured=bool(event.get_extra("adapter_preconfigured")), - ignore_at_all=self.ignore_at_all, + explicit_surface=bool(event.get_extra("explicit_surface")), ) ) event.message_str = route.message_str @@ -494,8 +484,7 @@ async def _detect_wake(self, event: AstrMessageEvent) -> WakeDecision: in {"notice", "request"}, has_open_window=has_open_window, is_manager_flush=manager_flush, - adapter_preconfigured=bool(event.get_extra("adapter_preconfigured")), - ignore_at_all=self.ignore_at_all, + explicit_surface=bool(event.get_extra("explicit_surface")), ) ) event.message_str = route.message_str diff --git a/astrbot/core/platform/manager.py b/astrbot/core/platform/manager.py index 8cb1e3e892..2ebe0e2885 100644 --- a/astrbot/core/platform/manager.py +++ b/astrbot/core/platform/manager.py @@ -646,7 +646,7 @@ def create_event( raise ValueError(f"Platform not found: {platform}") event = inst.create_event(cast(AstrBotMessage, event_message)) - event.set_extra("adapter_preconfigured", bool(is_wake)) + event.set_extra("explicit_surface", bool(is_wake)) inst.commit_event(event) def get_all_stats(self) -> dict: diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 537d33f987..bcff19c26f 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -349,17 +349,13 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No ) return - # 检查是否为斜杠指令 is_slash_command = message_event.interaction_followup_webhook is not None - # 1. 优先处理斜杠指令 if is_slash_command: - message_event.set_extra("adapter_preconfigured", True) + message_event.set_extra("explicit_surface", True) self.commit_event(message_event) return - # 2. 处理普通消息(提及检测) - # 确保 raw_message 是 discord.Message 类型,以便静态检查通过 raw_message = message.raw_message if not isinstance(raw_message, discord.Message): logger.warning( @@ -367,38 +363,6 @@ async def handle_msg(self, message: AstrBotMessage, followup_webhook=None) -> No ) return - # 检查是否被@(User Mention 或 Bot 拥有的 Role Mention) - is_mention = False - - # User Mention - # 此时 Pylance 知道 raw_message 是 discord.Message,具有 mentions 属性 - if self.client.user in raw_message.mentions: - is_mention = True - - # Role Mention(Bot 拥有的角色被提及) - if not is_mention and raw_message.role_mentions: - bot_member = None - if raw_message.guild: - try: - bot_member = raw_message.guild.get_member( - self.client.user.id, - ) - except Exception: - bot_member = None - if bot_member and hasattr(bot_member, "roles"): - bot_roles = set(bot_member.roles) - mentioned_roles = set(raw_message.role_mentions) - if ( - bot_roles - and mentioned_roles - and bot_roles.intersection(mentioned_roles) - ): - is_mention = True - - # 如果是被@的消息,设置为唤醒状态 - if is_mention: - message_event.set_extra("adapter_preconfigured", True) - self.commit_event(message_event) @override diff --git a/astrbot/core/platform/sources/webchat/webchat_adapter.py b/astrbot/core/platform/sources/webchat/webchat_adapter.py index 38f22ebdc4..a8b54693f9 100644 --- a/astrbot/core/platform/sources/webchat/webchat_adapter.py +++ b/astrbot/core/platform/sources/webchat/webchat_adapter.py @@ -268,6 +268,7 @@ def create_event(self, message: AstrBotMessage) -> WebChatMessageEvent: webchat_queue_manager=self._webchat_queue_manager, attachments_dir=self.attachments_dir, ) + message_event.set_extra("explicit_surface", True) raw_message = getattr(message, "raw_message", None) if isinstance(raw_message, tuple) and len(raw_message) >= 3: diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py index ba3dc46a70..833257017a 100644 --- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py +++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_adapter.py @@ -679,7 +679,7 @@ def create_event(self, message: AstrBotMessage) -> WecomAIBotMessageEvent: only_use_webhook_url_to_send=self.only_use_webhook_url_to_send, long_connection_sender=self._send_long_connection_respond_msg, ) - message_event.set_extra("adapter_preconfigured", True) + message_event.set_extra("explicit_surface", True) return message_event async def handle_msg(self, message: AstrBotMessage) -> None: diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index 0da99d8e8e..e6bda28fb3 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -997,7 +997,7 @@ "description": "Forward Message Word Count Threshold" }, "empty_mention_waiting": { - "description": "Trigger Waiting on Mention-only Messages" + "description": "Wait after a prefix-only message" } }, "command_prefixes": { @@ -1119,7 +1119,7 @@ "description": "Ignore Bot's Own Messages" }, "ignore_at_all": { - "description": "Ignore @All Events" + "description": "Ignore @everyone events (does not change the built-in LLM gate)" }, "no_permission_reply": { "description": "Reply When User Has Insufficient Permissions" diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 5459c9ad6f..48b8d3c979 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -991,7 +991,7 @@ "description": "转发消息的字数阈值" }, "empty_mention_waiting": { - "description": "只 @ 机器人是否触发等待" + "description": "只打指令前缀是否触发等待" } }, "command_prefixes": { @@ -1113,7 +1113,7 @@ "description": "是否忽略机器人自身的消息" }, "ignore_at_all": { - "description": "是否忽略 @ 全体成员事件" + "description": "是否忽略 @ 全体成员事件(不影响内置 LLM 门禁)" }, "no_permission_reply": { "description": "用户权限不足时是否回复" diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index 2fa65a22c1..c56031743f 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -170,7 +170,7 @@ Inbound routing is a single decision in `WakingCheckStage`: command, LLM, passth `TurnCoalesceStage` runs after the allow-list and session checks. When enabled, it hands eligible private-message LLM fragments to the lifecycle-owned, bounded `TurnWindowManager` without waiting in the pipeline. The manager merges fragments, pauses on NapCat typing notices, discards a buffered turn when a command arrives, and requeues one signed flush event through rate limiting and the remaining stages. Adapter-supplied flush flags are stripped; only manager-created events can carry `route_kind=turn_flush`. Notice and request events remain passthrough events, so ephemeral `input_status` never becomes an LLM message. -Group LLM access is controlled by `llm_access.group`, `llm_access.reply_to_bot`, and continuation state. Built-in command availability is stored per handler in the command database; `disable_builtin_commands` and `group_wake_policy` are not migrated, accepted by Dashboard config writes, or read by the Pipeline. Command configs use a stable `command_id` (`{plugin}:{original path}` with spaces as dots). Sync claims live handlers by `handler_full_name` then `command_id` and deletes unmatched rows. `alter_cmd` is read only under `command_id`; Python method names and historical short-name keys are not migrated. Built-in commands ignore persisted names and aliases unless `resolution_strategy` is `manual_rename`. Unused columns such as `keep_original_alias` are absent from the models; they are not dropped from an old file. +Group LLM access is controlled by `llm_access.group` (`open` / `prefix` / `off`), `llm_access.reply_to_bot`, and continuation state. Mentions are message-chain markers, not a wake policy. Built-in command availability is stored per handler in the command database; `disable_builtin_commands` and `group_wake_policy` are not migrated, accepted by Dashboard config writes, or read by the Pipeline. Command configs use a stable `command_id` (`{plugin}:{original path}` with spaces as dots). Sync claims live handlers by `handler_full_name` then `command_id` and deletes unmatched rows. `alter_cmd` is read only under `command_id`; Python method names and historical short-name keys are not migrated. Built-in commands ignore persisted names and aliases unless `resolution_strategy` is `manual_rename`. Unused columns such as `keep_original_alias` are absent from the models; they are not dropped from an old file. `platform_settings.group_sender_concurrency` is experimental and off by default. When it is on and `unique_session` is off, group LLM locks may split by sender so different members can generate in parallel. A whole turn still sends one-at-a-time per group UMO, and that turn is forced non-streaming. `AssistantHistoryCommitter` merges other senders' complete turns and never resurrects truncated history. Direct messages, WebChat, and cron jobs keep the original UMO lock. diff --git a/docs/en/dev/astrbot-config.md b/docs/en/dev/astrbot-config.md index e231f3c1b3..79dfd52314 100644 --- a/docs/en/dev/astrbot-config.md +++ b/docs/en/dev/astrbot-config.md @@ -43,7 +43,7 @@ At startup, AstrBot recursively inserts missing current defaults, fixes key orde | `dashboard` | WebUI listening, authentication, rate limiting, and TLS; Dashboard account identity and authoritative TOTP state live in its database. | | `platform` / `platform_specific` | Adapter instances and platform-specific behavior for Lark, Telegram, Discord, and others. | | `command_prefixes` | Command framing prefixes; default `["/"]`. | -| `llm_access` | Per-profile LLM access policy for direct and group messages; defaults to `private=open`, `group=prefix`, `prefixes=["/"]`. | +| `llm_access` | Per-profile LLM access policy for direct and group messages; defaults to `private=prefix`, `group=prefix`, `prefixes=["/"]`. | | `inbound_coalesce` | Optional bounded merging of consecutive private-message LLM fragments; disabled by default. | | Other top-level keys | Administrators, T2I, proxy, logging, timezone, plugins, knowledge base, Trace, and metrics. | @@ -53,32 +53,32 @@ Object layouts inside `provider_sources`, `provider`, and `platform` come from t User-facing steps are in [When the bot replies in groups](../use/group-wake). `command_prefixes` and `llm_access` are read from the configuration profile selected for the event. `command_prefixes` only frames command headers; it is never combined with an LLM prefix. Each `llm_access.prefixes` entry is the complete string users type, uses token-boundary matching, and follows longest-match semantics. Non-empty LLM prefixes reserve their first command-root token in the same profile, so a prefix that conflicts with an enabled command is rejected by the Dashboard. -| Key | Values | Meaning | -| ------------------------------------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| `llm_access.private` | `open` / `prefix` / `off` | Direct messages always pass, require an LLM prefix, or never open a new LLM turn. A continuation may still pass. | -| `llm_access.group` | `open` / `prefix` / `mention` / `prefix_or_mention` / `off` | Base gate for group LLM access. Mention and reply behavior are not inferred from command framing. | -| `llm_access.reply_to_bot` | `true` / `false` | Adds replying to the bot as an explicit OR condition for group LLM access. | -| `inbound_coalesce.enable` | `true` / `false` | Enables the bounded turn window; disabled by default. The current implementation coalesces private messages only. | -| `inbound_coalesce.wait_seconds` | Number | Quiet-period delay before a buffered turn is flushed. | -| `inbound_coalesce.max_total_seconds` | Number | Maximum lifetime of a buffered turn, regardless of new fragments. | -| `inbound_coalesce.max_typing_wait` | Number | Guard that resumes a paused turn when a typing-stop notice is lost. | +| Key | Values | Meaning | +| ------------------------------------ | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `llm_access.private` | `open` / `prefix` / `off` | Default `prefix`. Direct messages always pass, require an LLM prefix, or never open a new LLM turn. A continuation may still pass. | +| `llm_access.group` | `open` / `prefix` / `off` | Base gate for group LLM access. Mentions are not a gate; saved `mention` / `prefix_or_mention` fall back to `prefix` at runtime. | +| `llm_access.reply_to_bot` | `true` / `false` | Adds replying to the bot as an explicit OR condition for group LLM access. | +| `inbound_coalesce.enable` | `true` / `false` | Enables the bounded turn window; disabled by default. The current implementation coalesces private messages only. | +| `inbound_coalesce.wait_seconds` | Number | Quiet-period delay before a buffered turn is flushed. | +| `inbound_coalesce.max_total_seconds` | Number | Maximum lifetime of a buffered turn, regardless of new fragments. | +| `inbound_coalesce.max_typing_wait` | Number | Guard that resumes a paused turn when a typing-stop notice is lost. | Routing checks commands before LLM access. A matched command wins; a bare command group emits help; an unknown subcommand emits the Orbit diagnostic and never falls through to the LLM. Otherwise the event either passes the LLM gate or is dropped. Notices and requests are passthrough events. When coalescing is enabled, later private fragments continue an open turn without repeating the LLM prefix, while a command discards the buffered turn. NapCat `input_status` notices stay out of the message pipeline and only pause or resume the turn window. ## `platform_settings` -| Key | Default | Meaning | -| ------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `unique_session` | `false` | Split separate sessions for members inside a group. | -| `group_sender_concurrency` | `false` | Experimental. Different group senders may generate in parallel; a whole turn still sends one-at-a-time per group. Ignored when `unique_session` is on. | -| `rate_limit` | `60` seconds / `30` messages / `stall` | Wait (`stall`) or discard (`discard`) when the limit is exceeded. | -| `enable_id_white_list` | `true` | Enable the ID allowlist; the two `wl_ignore_admin_*` fields control administrator bypass. | -| `reply_prefix` | `""` | Prefix added to replies. | -| `reply_with_mention` / `reply_with_quote` | `false` | Mention the sender or quote the source message when supported by the adapter. | -| `forward_threshold` | `1500` | Long-reply forwarding threshold for the OneBot `aiocqhttp` adapter; support on other platforms depends on the adapter. | -| `segmented_reply` | See current defaults | Non-streaming segmentation, timing, and cleanup rules. | -| `path_mapping` | `[]` | Map paths from a platform container into paths AstrBot can read, using `source:target`. This is still used by the receive/respond pipeline. | -| `ignore_bot_self_message` / `ignore_at_all` | `false` | Ignore the bot's own messages or mass mentions. | +| Key | Default | Meaning | +| ----------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `unique_session` | `false` | Split separate sessions for members inside a group. | +| `group_sender_concurrency` | `false` | Experimental. Different group senders may generate in parallel; a whole turn still sends one-at-a-time per group. Ignored when `unique_session` is on. | +| `rate_limit` | `60` seconds / `30` messages / `stall` | Wait (`stall`) or discard (`discard`) when the limit is exceeded. | +| `enable_id_white_list` | `true` | Enable the ID allowlist; the two `wl_ignore_admin_*` fields control administrator bypass. | +| `reply_prefix` | `""` | Prefix added to replies. | +| `reply_with_mention` / `reply_with_quote` | `false` | Mention the sender or quote the source message when supported by the adapter. | +| `forward_threshold` | `1500` | Long-reply forwarding threshold for the OneBot `aiocqhttp` adapter; support on other platforms depends on the adapter. | +| `segmented_reply` | See current defaults | Non-streaming segmentation, timing, and cleanup rules. | +| `path_mapping` | `[]` | Map paths from a platform container into paths AstrBot can read, using `source:target`. This is still used by the receive/respond pipeline. | +| `ignore_bot_self_message` | `false` | Ignore the bot's own messages. `ignore_at_all` is still persisted, but it is not an LLM gate. | Example path mapping: diff --git a/docs/en/faq.md b/docs/en/faq.md index 6c5bccb529..cb36a8e503 100644 --- a/docs/en/faq.md +++ b/docs/en/faq.md @@ -103,12 +103,12 @@ Stop the process, back up `data/`, then delete `data/data_v4.db`, `data/data_v4. ### The bot does not answer in a group -To avoid flooding group chats, ordinary messages require the configured `llm_access.group` policy (the default is `prefix`, with `llm_access.prefixes` defaulting to `["/"]`). To allow replies to the bot as an additional trigger, enable `llm_access.reply_to_bot`. Full policy, isolated sessions, and drops that still happen after a wake are in [When the bot replies in groups](/en/use/group-wake). Also check: +To avoid flooding group chats, ordinary messages require the configured `llm_access.group` policy (the default is `prefix`, with `llm_access.prefixes` defaulting to `["/"]`). Direct messages default to `prefix` as well. `@` does not extra-admit the built-in AI. To allow replies to the bot as an additional trigger, enable `llm_access.reply_to_bot`. Full policy, isolated sessions, and drops that still happen after a wake are in [When the bot replies in groups](/en/use/group-wake). Also check: - which profile is bound to the message session, see [Configuration profiles](/en/use/config-profiles); - whether the platform and Provider are enabled; - allowlist, administrator bypass, and rate limiting, see [Platform handling](/en/use/platform-settings); -- `ignore_at_all`, self-message filtering, and platform permissions. +- self-message filtering and platform permissions. ### An administrator command says permission denied diff --git a/docs/en/use/group-wake.md b/docs/en/use/group-wake.md index 6481940823..f7b9ac3c64 100644 --- a/docs/en/use/group-wake.md +++ b/docs/en/use/group-wake.md @@ -2,7 +2,7 @@ If the bot ignores a group message, the model is usually fine. The active configuration profile's LLM access policy did not admit that message. -This page only covers **whether a message is sent to the AI**. Injecting recent group messages into the next request is [Group Chat Context Awareness](./group-chat-context). +This page only covers **whether a message is sent to the built-in AI**. Injecting recent group messages into the next request is [Group Chat Context Awareness](./group-chat-context). Open **Config → Platform → General**. These fields belong to the current profile. Editing `default` does not change a group bound to another profile. See [Configuration profiles](./config-profiles). @@ -10,12 +10,14 @@ Open **Config → Platform → General**. These fields belong to the current pro | Scene | Default | Effect | | -------------- | --------------------------------- | -------------------------------------- | -| Direct message | `llm_access.private = open` | Ordinary text goes to the LLM | +| Direct message | `llm_access.private = prefix` | Requires an LLM prefix | | Group | `llm_access.group = prefix` | Requires an LLM prefix | | LLM prefix | `llm_access.prefixes = ["/"]` | `/hello` is handled; `hello` is not | | Reply to bot | `llm_access.reply_to_bot = false` | Replying does not extra-admit the turn | -After a default install: talk freely in DMs. In a group, `/hello` starts a chat and `hello` does not. +After a default install, both IM direct messages and groups need an LLM prefix. `open` remains a legal opt-in. Sending from Dashboard WebChat is an explicit aim at the built-in AI and does not need a prefix. + +`@`, `Mention`, and `MentionAll` are message-chain markers. Their presence does not change the built-in LLM gate. Saved `mention` / `prefix_or_mention` values are treated as `prefix` at runtime and are not rewritten on disk. ## Command prefixes and LLM prefixes are separate @@ -29,7 +31,7 @@ Routing **always matches commands before LLM access**: 1. An enabled command runs as a command. The model is not called. 2. A bare command group (for example `/plugin`) shows subcommand help. 3. An unknown subcommand returns an Orbit diagnostic and is **not** treated as an LLM prompt. -4. Everything else follows `llm_access`. +4. Everything else follows `llm_access`, continuation, and the explicit-surface stamp. Both prefix lists default to `/`. A non-empty LLM prefix occupies that profile's first command root. Dashboard rejects a save that conflicts with an enabled command. See [Built-in commands](./command). @@ -37,39 +39,47 @@ Both prefix lists default to `/`. A non-empty LLM prefix occupies that profile's `llm_access.group` values: -| Value | When the LLM runs | -| ------------------- | ------------------------------------------- | -| `open` | Every ordinary group message. Easy to flood | -| `prefix` | Default. Message starts with an LLM prefix | -| `mention` | @ the bot, or @ everyone (see exceptions) | -| `prefix_or_mention` | Prefix **or** mention | -| `off` | Do not open a new LLM turn | +| Value | When the LLM runs | +| -------- | ------------------------------------------- | +| `open` | Every ordinary group message. Easy to flood | +| `prefix` | Default. Message starts with an LLM prefix | +| `off` | Do not open a new LLM turn | Two extra conditions: - **Allow LLM when replying to the bot** (`reply_to_bot`): an additional OR. Even `group=off` can wake on a reply to the bot. -- **@ everyone**: if the group policy is not `off` and `ignore_at_all` is off, inbound `MentionAll` admits the LLM. `Mention(target="all")` is not everyone. `ignore_at_all` lives under **Platform → Other**. +- **Continuation**: later fragments of an open LLM turn window do not need to repeat the prefix. -A message that starts by @-ing **someone else** does not wake on prefix. That keeps talk directed at other people from hitting the bot. +A message that starts by @-ing **someone else** does not wake on prefix. That keeps talk directed at other people from hitting the bot. The mention itself does not admit the LLM, and `MentionAll` does not extra-admit either. ### Suggested recipes - Large group, low noise: keep `prefix`. -- Only answer mentions: set `mention`. - Also answer replies: turn on `reply_to_bot`. - Anyone may chat: set `open` (this raises call volume). -`llm_access.private` is only `open` / `prefix` / `off`. `off` blocks new LLM turns; an in-flight continuation may still continue. +`llm_access.private` is only `open` / `prefix` / `off`. The default is `prefix`. `off` blocks new LLM turns; an in-flight continuation may still continue. + +## Explicit surfaces + +These inbound paths stamp `explicit_surface`. After a command miss, the built-in LLM is admitted. `private=off` / `group=off` still block a new turn; the stamp does not bypass that kill switch: + +- Discord slash commands / interaction follow-up +- Dashboard WebChat sends +- WeCom AI Bot inbound +- Cron jobs +- `PlatformManager.create_event(..., is_wake=True)` +- The follow-up after a prefix-only wait + +Discord user mentions and bot-owned role mentions **do not** receive this stamp; they still follow the `llm_access` gate above. WebChat does not inject `/` into `message_str`: `hello` goes to the LLM; a user-typed `/help` still matches commands first. ## Continuations If the session already has an open LLM turn window (inbound coalesce waiting for the rest of a sentence, or a turn still running), later fragments can continue without repeating the prefix. A real command discards the buffered turn. Inbound coalesce is off by default and currently merges **direct messages only**. See [Platform handling](./platform-settings#inbound-turn-coalescing). -Some adapters stamp a preconfigured wake flag and bypass the group `llm_access` gate. Do not use that as a substitute for the policy above. - -## Mention-only messages +## Prefix-only messages -**Platform → General → Wait after a mention-only message** (`empty_mention_waiting`) is on by default. A message that is only @-bot waits up to 60 seconds for that user's next message. This is not `llm_access` and is not a complete turn by itself. +`empty_mention_waiting` is on by default. A message that is only a command prefix (for example a lone `/`) is not sent to the built-in LLM as an empty prompt. It waits up to 60 seconds for that user's next message and resubmits it with `explicit_surface`, so the follow-up does not need another prefix. Turning the switch off skips the wait and does not call the model. An empty `@` does not start the built-in AI and does not reinsert a `Mention` for resubmit. `private=off` / `group=off` skips the wait and the courtesy prompt. ## Isolated sessions @@ -85,14 +95,14 @@ Isolation changes **who owns the context**. It does not relax `llm_access.group` Pipeline order: wake check → [allowlist](./platform-settings#allowlist) → session enabled → coalesce → rate limit → content safety → preprocess → plugin or LLM. -A policy admit can still vanish behind an allowlist, a custom rule that disabled the session, rate limits, or content safety. Session on/off is in [Custom rules](./custom-rules). +A policy admit can still vanish behind an allowlist, a custom rule that disabled the session, rate limits, or content safety. Session on/off is in [Custom rules](./custom-rules). Unmatched ordinary text still reaches plugin `EventMessageType.ALL` / `PRIVATE_MESSAGE` listeners. ## Common misconfigurations -1. Talking plainly in a group while `group` is still the default `prefix`. +1. Talking plainly in a group or DM while the policy is still the default `prefix`. 2. Changing `command_prefixes` and expecting the LLM prefix to follow. 3. Editing `default` while the group is bound to another profile. Check the UMO with `/session info`. -4. Setting `mention` on a platform that does not parse @ into a `Mention` segment. @ everyone must be `MentionAll`; `Mention(target="all")` is not everyone. +4. Assuming @-ing the bot starts the built-in AI. Mentions are message-chain markers. 5. Assuming a reply equals a mention; `reply_to_bot` is a separate switch. 6. Allowlist enabled with a non-empty list that omits this group. 7. A custom rule or `/llm disable` turned LLM off for the session. diff --git a/docs/en/use/platform-settings.md b/docs/en/use/platform-settings.md index a78c345eca..2230dacfa8 100644 --- a/docs/en/use/platform-settings.md +++ b/docs/en/use/platform-settings.md @@ -58,8 +58,7 @@ Streaming replies, and groups with sender concurrency, do not use this splitter. - **Reply prefix / mention sender / quote original**: adapter-dependent. - **Ignore the bot's own messages**: some platforms re-deliver the bot's messages from other clients. -- **Ignore @ everyone**: when on, @ everyone is no longer an LLM wake reason. - **Reply on missing permission**: whether to tell the user a command was denied. -- **Wait after a mention-only message**: see [When the bot replies in groups](./group-wake#mention-only-messages). +- **Wait after a prefix-only message**: gated by `empty_mention_waiting`. See [When the bot replies in groups](./group-wake#prefix-only-messages). Pre-ack emoji for Lark / Telegram / Discord sit under Other, per platform. diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index 89a9681fa4..834fa415c5 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -170,7 +170,7 @@ Mixin 通过带类型的 `store_session(self)` 助手获取会话,不直接持 `TurnCoalesceStage` 位于白名单和会话检查之后。启用时,它把符合条件的私聊 LLM 消息片段交给生命周期持有的有界 `TurnWindowManager`,不会在流水线中等待。管理器负责合并片段、根据 NapCat 输入状态暂停、收到指令时丢弃未完成回合,并重新排队一个带签名的 flush 事件,让它经过限流及后续阶段。适配器提供的 flush 标志会被清除,只有管理器创建的事件可以携带 `route_kind=turn_flush`。通知和请求保持透传,因此临时的 `input_status` 不会变成 LLM 消息。 -群聊 LLM 访问由 `llm_access.group`、`llm_access.reply_to_bot` 和续片状态控制。内置命令是否可用则按 handler 存储在命令数据库中;`disable_builtin_commands` 和 `group_wake_policy` 不迁移、不接受配置写入,也不被 Pipeline 读取。指令配置以 `command_id`(`{plugin}:{original path}`,空格换成点)为稳定标识;同步时按 `handler_full_name` 再按 `command_id` 认领活 handler,认领失败的行删除。`alter_cmd` 只读取 `command_id` 键,不从 Python 方法名或历史短名迁移。内置指令在 `resolution_strategy` 不是 `manual_rename` 时忽略库中的名字和别名覆盖。`keep_original_alias` 等未使用列不在模型上,也不会从旧文件删除。 +群聊 LLM 访问由 `llm_access.group`(`open` / `prefix` / `off`)、`llm_access.reply_to_bot` 和续片状态控制。提及只是消息链标记,不是唤醒策略。内置命令是否可用则按 handler 存储在命令数据库中;`disable_builtin_commands` 和 `group_wake_policy` 不迁移、不接受配置写入,也不被 Pipeline 读取。指令配置以 `command_id`(`{plugin}:{original path}`,空格换成点)为稳定标识;同步时按 `handler_full_name` 再按 `command_id` 认领活 handler,认领失败的行删除。`alter_cmd` 只读取 `command_id` 键,不从 Python 方法名或历史短名迁移。内置指令在 `resolution_strategy` 不是 `manual_rename` 时忽略库中的名字和别名覆盖。`keep_original_alias` 等未使用列不在模型上,也不会从旧文件删除。 `platform_settings.group_sender_concurrency` 是实验性开关,默认关闭。启用且未开 `unique_session` 时,群聊 LLM 锁可按发送者拆分,不同群友可并行生成;整轮出站仍按群 UMO 排队,本轮强制非流式。对话历史在 `AssistantHistoryCommitter` 内合并并发完整轮次,不复活已截断历史。私聊、WebChat 和定时任务保持原 UMO 串行。 diff --git a/docs/zh/dev/astrbot-config.md b/docs/zh/dev/astrbot-config.md index 9defe69049..b0dc23f45f 100644 --- a/docs/zh/dev/astrbot-config.md +++ b/docs/zh/dev/astrbot-config.md @@ -43,7 +43,7 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 | `dashboard` | WebUI 监听、认证、限流和 TLS;账户身份及 TOTP 权威状态由 Dashboard 数据库保存。 | | `platform` / `platform_specific` | 平台实例,以及 Lark、Telegram、Discord 等平台特异行为。 | | `command_prefixes` | 指令头前缀,默认 ["/"]。 | -| `llm_access` | 当前配置档的私聊和群聊 LLM 访问策略;默认 `private=open`、`group=prefix`、`prefixes=["/"]`。 | +| `llm_access` | 当前配置档的私聊和群聊 LLM 访问策略;默认 `private=prefix`、`group=prefix`、`prefixes=["/"]`。 | | `inbound_coalesce` | 可选的连续私聊 LLM 消息有界合并,默认关闭。 | | 其他顶层键 | 管理员、T2I、代理、日志、时区、插件、知识库、Trace 和指标等。 | @@ -53,15 +53,15 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 用户向步骤见 [群聊何时会理我](../use/group-wake)。`command_prefixes` 和 `llm_access` 都读取事件实际选中的配置档。`command_prefixes` 只负责指令头,不会与 LLM 前缀自动拼接。`llm_access.prefixes` 的每一项都是用户实际输入的完整字符串,按词边界和最长匹配处理。非空 LLM 前缀会在同一配置档占用其第一个指令根;如果与已启用指令冲突,Dashboard 会拒绝保存。 -| 键 | 可选值 | 说明 | -| ------------------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------ | -| `llm_access.private` | `open` / `prefix` / `off` | 私聊始终允许、必须带 LLM 前缀,或不打开新的 LLM 回合;已有续片仍可继续。 | -| `llm_access.group` | `open` / `prefix` / `mention` / `prefix_or_mention` / `off` | 群聊 LLM 的基础门禁;不会从指令前缀推断提及或回复条件。 | -| `llm_access.reply_to_bot` | `true` / `false` | 将“回复机器人”作为群聊 LLM 访问的额外 OR 条件。 | -| `inbound_coalesce.enable` | `true` / `false` | 启用有界回合窗口,默认关闭;当前实现只合并私聊消息。 | -| `inbound_coalesce.wait_seconds` | 数字 | 缓冲回合的静默等待时间。 | -| `inbound_coalesce.max_total_seconds` | 数字 | 缓冲回合的最长生命周期,不因新片段而延长。 | -| `inbound_coalesce.max_typing_wait` | 数字 | 输入停止通知丢失时,自动恢复暂停回合的保护时间。 | +| 键 | 可选值 | 说明 | +| ------------------------------------ | ------------------------- | ------------------------------------------------------------------------------------------------ | +| `llm_access.private` | `open` / `prefix` / `off` | 默认 `prefix`。私聊始终允许、必须带 LLM 前缀,或不打开新的 LLM 回合;已有续片仍可继续。 | +| `llm_access.group` | `open` / `prefix` / `off` | 群聊 LLM 的基础门禁。提及不是门禁;旧值 `mention` / `prefix_or_mention` 运行时按 `prefix` 处理。 | +| `llm_access.reply_to_bot` | `true` / `false` | 将“回复机器人”作为群聊 LLM 访问的额外 OR 条件。 | +| `inbound_coalesce.enable` | `true` / `false` | 启用有界回合窗口,默认关闭;当前实现只合并私聊消息。 | +| `inbound_coalesce.wait_seconds` | 数字 | 缓冲回合的静默等待时间。 | +| `inbound_coalesce.max_total_seconds` | 数字 | 缓冲回合的最长生命周期,不因新片段而延长。 | +| `inbound_coalesce.max_typing_wait` | 数字 | 输入停止通知丢失时,自动恢复暂停回合的保护时间。 | 路由顺序是先匹配指令,再判断 LLM 访问。命中指令时只执行指令;裸指令组输出帮助;未知子指令输出 Orbit 诊断且不会回落到 LLM。否则事件通过 LLM 门禁或被丢弃。通知和请求属于透传事件。启用合并后,私聊窗口中的后续片段不再要求重复 LLM 前缀;收到指令会丢弃缓冲回合。NapCat 的 `input_status` 只暂停或恢复回合窗口,不会进入消息 Pipeline。 @@ -69,18 +69,18 @@ WebUI 创建的其他配置档位于 `data/config/abconf_.json`。消息 常用字段如下: -| 键 | 默认值 | 说明 | -| ------------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- | -| `unique_session` | `false` | 是否为群内成员拆分独立会话。 | -| `group_sender_concurrency` | `false` | 实验性。同群不同发送者可并行生成,发送仍按群整轮排队。与 `unique_session` 互斥;会关闭同群流式。 | -| `rate_limit` | `60` 秒 / `30` 条 / `stall` | 超限时等待(`stall`)或丢弃(`discard`)。 | -| `enable_id_white_list` | `true` | 启用 ID 白名单;管理员是否绕过由两个 `wl_ignore_admin_*` 字段控制。 | -| `reply_prefix` | `""` | 所有回复的前缀。 | -| `reply_with_mention` / `reply_with_quote` | `false` | @ 用户或引用原消息,实际能力取决于适配器。 | -| `forward_threshold` | `1500` | OneBot `aiocqhttp` 适配器的长回复转发阈值;其他平台是否支持取决于适配器。 | -| `segmented_reply` | 见默认配置 | 非流式结果的分段、间隔、清理规则。 | -| `path_mapping` | `[]` | 将平台事件中的容器路径映射到 AstrBot 可访问路径,格式为 `原路径:目标路径`。该功能仍在收发 pipeline 中使用。 | -| `ignore_bot_self_message` / `ignore_at_all` | `false` | 忽略机器人自身消息或全体提及。 | +| 键 | 默认值 | 说明 | +| ----------------------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `unique_session` | `false` | 是否为群内成员拆分独立会话。 | +| `group_sender_concurrency` | `false` | 实验性。同群不同发送者可并行生成,发送仍按群整轮排队。与 `unique_session` 互斥;会关闭同群流式。 | +| `rate_limit` | `60` 秒 / `30` 条 / `stall` | 超限时等待(`stall`)或丢弃(`discard`)。 | +| `enable_id_white_list` | `true` | 启用 ID 白名单;管理员是否绕过由两个 `wl_ignore_admin_*` 字段控制。 | +| `reply_prefix` | `""` | 所有回复的前缀。 | +| `reply_with_mention` / `reply_with_quote` | `false` | @ 用户或引用原消息,实际能力取决于适配器。 | +| `forward_threshold` | `1500` | OneBot `aiocqhttp` 适配器的长回复转发阈值;其他平台是否支持取决于适配器。 | +| `segmented_reply` | 见默认配置 | 非流式结果的分段、间隔、清理规则。 | +| `path_mapping` | `[]` | 将平台事件中的容器路径映射到 AstrBot 可访问路径,格式为 `原路径:目标路径`。该功能仍在收发 pipeline 中使用。 | +| `ignore_bot_self_message` | `false` | 忽略机器人自身消息。`ignore_at_all` 仍会写入磁盘,但不参与内置 LLM 门禁。 | `path_mapping` 示例: diff --git a/docs/zh/faq.md b/docs/zh/faq.md index c05a63a35a..5a5e39fdcf 100644 --- a/docs/zh/faq.md +++ b/docs/zh/faq.md @@ -103,12 +103,12 @@ uv run python scripts/sync_dashboard_dist.py ### 群聊中机器人不回复 -为避免群消息泛滥,普通消息需要满足配置档的 `llm_access.group` 策略(默认是 `prefix`,`llm_access.prefixes` 默认值为 `["/"]`)。如需把“回复机器人”作为额外触发条件,请启用 `llm_access.reply_to_bot`。完整策略、隔离会话和唤醒之后仍可能被白名单丢掉的说明见 [群聊何时会理我](/use/group-wake)。同时检查: +为避免群消息泛滥,普通消息需要满足配置档的 `llm_access.group` 策略(默认是 `prefix`,`llm_access.prefixes` 默认值为 `["/"]`)。私聊默认同样是 `prefix`。`@` 不会单独放行内置 AI。如需把“回复机器人”作为额外触发条件,请启用 `llm_access.reply_to_bot`。完整策略、隔离会话和唤醒之后仍可能被白名单丢掉的说明见 [群聊何时会理我](/use/group-wake)。同时检查: - 当前配置档是否绑定到该消息会话,见 [配置文件](/use/config-profiles); - 平台和 Provider 是否启用; - 白名单、管理员绕过和限流,见 [平台处理](/use/platform-settings); -- `ignore_at_all`、机器人自身消息过滤及平台权限。 +- 机器人自身消息过滤及平台权限。 ### 管理员指令提示无权限 diff --git a/docs/zh/use/group-wake.md b/docs/zh/use/group-wake.md index 0b1d72718b..b9bd88f917 100644 --- a/docs/zh/use/group-wake.md +++ b/docs/zh/use/group-wake.md @@ -2,20 +2,22 @@ 群里发了一句话,机器人没有回复。多数情况下不是模型坏了,而是当前配置文件的 LLM 访问策略没有放行这条消息。 -本页只说明**会不会把这条消息交给 AI**。群聊里把近期消息注入下一次请求,见 [群聊上下文感知](./group-chat-context)。 +本页只说明**会不会把这条消息交给内置 AI**。群聊里把近期消息注入下一次请求,见 [群聊上下文感知](./group-chat-context)。 入口:WebUI **配置文件 → 平台配置 → 基本**。这些字段属于当前配置文件,改 `default` 不一定影响已经绑定到其他配置文件的群。配置文件与绑定见 [配置文件](./config-profiles)。 ## 默认行为 -| 场景 | 默认值 | 效果 | -| ---------- | --------------------------------- | --------------------- | -| 私聊 | `llm_access.private = open` | 普通文字就会进 LLM | -| 群聊 | `llm_access.group = prefix` | 需要带 LLM 前缀 | -| LLM 前缀 | `llm_access.prefixes = ["/"]` | 群里发 `/你好` 才会理 | -| 回复机器人 | `llm_access.reply_to_bot = false` | 点回复不会额外放行 | +| 场景 | 默认值 | 效果 | +| ---------- | --------------------------------- | ------------------------------ | +| 私聊 | `llm_access.private = prefix` | 需要带 LLM 前缀 | +| 群聊 | `llm_access.group = prefix` | 需要带 LLM 前缀 | +| LLM 前缀 | `llm_access.prefixes = ["/"]` | 发 `/你好` 才会理;`你好` 不会 | +| 回复机器人 | `llm_access.reply_to_bot = false` | 点回复不会额外放行 | -所以默认安装后:私聊直接说话即可;群里发「你好」会忽略,发 `/你好` 才会对话。 +所以默认安装后:IM 私聊和群聊都要带 LLM 前缀。`open` 仍是合法的 opt-in。Dashboard WebChat 在聊天框发送视为显式对准内置 AI,不必再打前缀。 + +`@`、`Mention`、`MentionAll` 只是消息链标记。有没有 @ 对内置 LLM 门禁没有差别。旧配置里的 `mention` / `prefix_or_mention` 运行时按 `prefix` 处理,不会改写磁盘上的配置文件。 ## 指令前缀和 LLM 前缀不是同一个开关 @@ -29,7 +31,7 @@ 1. 命中已启用指令:只执行指令,不再问模型。 2. 只打了指令组名(例如 `/plugin`):显示子指令帮助。 3. 未知子指令:返回 Orbit 诊断,**不会**把这句话当成 LLM 提示词。 -4. 其余消息再看 `llm_access`。 +4. 其余消息再看 `llm_access`、续片,以及显式表面标记。 两条前缀都默认是 `/`。把 LLM 前缀改成 `/chat` 之类非空值时,它会占用该配置文件的第一个指令根;如果和已启用指令冲突,Dashboard 会拒绝保存。指令命名空间见 [内置指令](./command)。 @@ -37,39 +39,47 @@ `llm_access.group` 可选值: -| 值 | 何时会理 LLM | -| ------------------- | --------------------------------- | -| `open` | 普通群消息都理,容易刷屏 | -| `prefix` | 默认。消息以 LLM 前缀开头 | -| `mention` | @ 机器人,或 @ 全体(见下方例外) | -| `prefix_or_mention` | 前缀 **或** @ | -| `off` | 不开新的 LLM 回合 | +| 值 | 何时会理 LLM | +| -------- | ------------------------- | +| `open` | 普通群消息都理,容易刷屏 | +| `prefix` | 默认。消息以 LLM 前缀开头 | +| `off` | 不开新的 LLM 回合 | 另外两个独立条件: - **回复机器人时允许 LLM**(`reply_to_bot`):作为额外的 OR 条件。群策略即使是 `off`,回复机器人仍可唤醒。 -- **@ 全体**:只要群策略不是 `off`,并且没有开启 `ignore_at_all`,入站 `MentionAll` 会放行 LLM。`Mention(target="all")` 不算全体。`ignore_at_all` 在 **平台配置 → 其他配置**。 +- **续片**:当前会话已有打开的 LLM 回合窗口时,后续片段不必再打前缀。 -消息以 @ **别人** 开头时,不会按前缀唤醒,避免把发给其他人的话当成对机器人说。 +消息以 @ **别人** 开头时,不会按前缀唤醒,避免把发给其他人的话当成对机器人说。@ 本身不会放行 LLM;`MentionAll` 也不会额外放行。 ### 推荐配方 - 群比较大、不想被随便喊:保持 `prefix`。 -- 希望 @ 才理:设为 `mention`。 - 希望点回复也理:再打开 `reply_to_bot`。 - 希望群里人人都能聊:设为 `open`(会显著增加调用量)。 -私聊 `llm_access.private` 只有 `open` / `prefix` / `off`。设为 `off` 后不会开新的 LLM 回合;已经开始的续片仍可继续。 +私聊 `llm_access.private` 只有 `open` / `prefix` / `off`。默认是 `prefix`。设为 `off` 后不会开新的 LLM 回合;已经开始的续片仍可继续。 + +## 显式表面 + +下列入站会打上 `explicit_surface`,指令未命中时放行内置 LLM。`private=off` / `group=off` 仍是总开关,显式表面不会绕过: + +- Discord 斜杠指令 / interaction follow-up +- Dashboard WebChat 聊天框发送 +- 企业微信智能机器人全量入站 +- 定时任务(cron) +- `PlatformManager.create_event(..., is_wake=True)` +- 只打指令前缀后等待到的下一条消息 + +Discord 的用户提及或机器人角色提及**不会**打这个标记,仍然走上面的 `llm_access` 门禁。WebChat 不会把 `/` 写进 `message_str`:`hello` 进 LLM;用户自己打的 `/help` 仍先走指令。 ## 已经在聊时的续片 如果当前会话已经打开一个 LLM 回合窗口(例如入站合并还在等后半句,或上一轮还没结束),后续片段可以继续,不必再打前缀。收到一条真正的指令会丢掉缓冲中的回合。入站合并默认关闭,而且当前实现只合并私聊,见 [平台处理](./platform-settings#入站回合合并)。 -部分适配器会给事件打上预配置唤醒标记,这时会绕过群聊 `llm_access` 门禁。不要依赖这个行为来代替上面的策略。 - -## 只 @ 机器人、没有正文 +## 只打了指令前缀、没有正文 -**平台配置 → 基本** 里的「只 @ 机器人是否触发等待」(`empty_mention_waiting`)默认开启:消息内容只有 @ 机器人时,会等待该用户 60 秒内的下一条消息。这不是 `llm_access`,也不会单独构成一次完整对话。 +`empty_mention_waiting` 默认开启。消息内容只有指令前缀(例如单独一个 `/`)时,内置 LLM 不会把这句话当成空提示词;会等待该用户 60 秒内的下一条消息,并把后续内容打上 `explicit_surface` 再投递,因此不必再打前缀。关掉该开关后,单独的 `/` 不会进入等待,也不会问模型。空 `@` 不会启动内置 AI,也不会插入 `Mention` 再投递。`private=off` / `group=off` 时不等待、不发送礼貌询问。 ## 隔离会话 @@ -85,14 +95,14 @@ 流水线顺序是:唤醒检查 → [白名单](./platform-settings#白名单) → 会话是否启用 → 入站合并 → 限流 → 内容安全 → 预处理 → 插件或 LLM。 -所以「策略已经放行」之后,还可能因为白名单、自定义规则关掉了该会话、限流或内容安全而没有回复。会话启停见 [自定义规则](./custom-rules)。 +所以「策略已经放行」之后,还可能因为白名单、自定义规则关掉了该会话、限流或内容安全而没有回复。会话启停见 [自定义规则](./custom-rules)。未匹配的普通文本仍会交给插件的 `EventMessageType.ALL` / `PRIVATE_MESSAGE` 监听。 ## 常见误配 -1. 群里直接说话,但 `group` 仍是默认的 `prefix`。 +1. 群里或私聊直接说话,但策略仍是默认的 `prefix`。 2. 改了 `command_prefixes`,以为 LLM 前缀会一起变。 3. 改了 `default`,群其实绑定在另一份配置文件上。用 `/session info` 核对 UMO,再看配置文件页当前文件。 -4. 打开了 `mention`,但平台并没有把 @ 解析成 `Mention` 消息段;@ 全体必须是 `MentionAll`,`Mention(target="all")` 不算。 +4. 以为 @ 机器人就会进内置 AI。提及只是消息链标记。 5. 以为回复机器人等于 @;需要显式打开 `reply_to_bot`。 6. 白名单已启用且列表非空,当前群 ID 不在其中。 7. 自定义规则或 `/llm disable` 关掉了该会话的 LLM。 diff --git a/docs/zh/use/platform-settings.md b/docs/zh/use/platform-settings.md index ba1f04f554..32c37b70e8 100644 --- a/docs/zh/use/platform-settings.md +++ b/docs/zh/use/platform-settings.md @@ -58,8 +58,7 @@ - **回复前缀 / @ 发送人 / 引用原消息**:实际能力取决于适配器。 - **忽略机器人自身消息**:某些平台会把机器人在其他端发的消息再推回来。 -- **忽略 @ 全体**:关掉之后,@ 全体不再作为 LLM 唤醒条件。 - **权限不足时回复**:用户没权限执行指令时是否提示。 -- **只 @ 机器人是否触发等待**:见 [群聊何时会理我](./group-wake#只--机器人没有正文)。 +- **只打指令前缀是否触发等待**:由 `empty_mention_waiting` 控制,见 [群聊何时会理我](./group-wake#只打了指令前缀、没有正文)。 飞书 / Telegram / Discord 的预回应表情在「其他配置」里,按平台分开。 diff --git a/tests/unit/platform/test_discord_adapter.py b/tests/unit/platform/test_discord_adapter.py index daa0e9cd29..9121efed82 100644 --- a/tests/unit/platform/test_discord_adapter.py +++ b/tests/unit/platform/test_discord_adapter.py @@ -250,7 +250,9 @@ async def test_discord_convert_message_strips_nickname_mention_prefix(): @pytest.mark.asyncio -async def test_discord_handle_msg_sets_wake_when_bot_role_is_mentioned(monkeypatch): +async def test_discord_handle_msg_does_not_stamp_when_bot_role_is_mentioned( + monkeypatch, +): class FakeDiscordMessage: pass @@ -297,11 +299,48 @@ def fake_create_event(_message, _followup_webhook=None): await adapter.handle_msg(message) assert len(committed_events) == 1 - committed_events[0].set_extra.assert_called_once_with( - "adapter_preconfigured", - True, + committed_events[0].set_extra.assert_not_called() + + +@pytest.mark.asyncio +async def test_discord_handle_msg_does_not_stamp_when_user_is_mentioned(monkeypatch): + class FakeDiscordMessage: + pass + + monkeypatch.setattr(discord_platform_adapter.discord, "Message", FakeDiscordMessage) + + adapter = DiscordPlatformAdapter.__new__(DiscordPlatformAdapter) + user = SimpleNamespace(id=1) + adapter.client = SimpleNamespace(user=user) + committed_events = [] + adapter.commit_event = committed_events.append + + raw_message = FakeDiscordMessage() + raw_message.mentions = [user] + raw_message.role_mentions = [] + raw_message.guild = None + + message = SimpleNamespace( + raw_message=raw_message, + message_str="hello", + session_id="555", + message=[], ) + def fake_create_event(_message, _followup_webhook=None): + return SimpleNamespace( + interaction_followup_webhook=None, + _extras={}, + set_extra=MagicMock(), + ) + + adapter.create_event = fake_create_event + + await adapter.handle_msg(message) + + assert len(committed_events) == 1 + committed_events[0].set_extra.assert_not_called() + @pytest.mark.asyncio async def test_discord_handle_msg_skips_when_client_not_ready(): @@ -384,7 +423,7 @@ def fake_create_event(_message, _followup_webhook=None): assert len(committed_events) == 1 committed_events[0].set_extra.assert_called_once_with( - "adapter_preconfigured", + "explicit_surface", True, ) diff --git a/tests/unit/platform/test_wecom_ai_bot_adapter.py b/tests/unit/platform/test_wecom_ai_bot_adapter.py index 1f675cfa7b..c7b7951fd4 100644 --- a/tests/unit/platform/test_wecom_ai_bot_adapter.py +++ b/tests/unit/platform/test_wecom_ai_bot_adapter.py @@ -221,7 +221,7 @@ def test_wecom_ai_bot_create_event_marks_wake_flags_and_injects_dependencies(): assert event.api_client is adapter.api_client assert event.webhook_client is adapter.webhook_client assert event.long_connection_sender is adapter._send_long_connection_respond_msg - assert event.get_extra("adapter_preconfigured") is True + assert event.get_extra("explicit_surface") is True assert event.platform_member_role == "member" assert event.platform_role_source == "none" diff --git a/tests/unit/test_aiocqhttp_adapter.py b/tests/unit/test_aiocqhttp_adapter.py index 4a50fc917e..0e616f0814 100644 --- a/tests/unit/test_aiocqhttp_adapter.py +++ b/tests/unit/test_aiocqhttp_adapter.py @@ -311,8 +311,8 @@ async def test_aiocqhttp_reply_only_wake_resolves_sender_lazily(monkeypatch): "command_prefixes": ["/"], "llm_access": { "prefixes": ["/"], - "private": "open", - "group": "mention", + "private": "prefix", + "group": "prefix", "reply_to_bot": True, }, "plugin_set": ["*"], diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 9178e3a53d..686736ed34 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -169,6 +169,31 @@ def test_init_with_schema(self, temp_config_path): assert config.nested["enabled"] is False assert config.nested["count"] == 0 + def test_schema_save_replace_keeps_schema_keys(self, temp_config_path): + """Plugin schema defaults must be the save-time reference, not DEFAULT_CONFIG.""" + schema = { + "enabled": {"type": "bool", "default": True}, + "nested": { + "type": "object", + "items": { + "value": {"type": "string", "default": "a"}, + }, + }, + } + config = AstrBotConfig(config_path=temp_config_path, schema=schema) + + config.save_config( + replace_config={"enabled": False, "nested": {"value": "b"}}, + ) + + with open(temp_config_path, encoding="utf-8-sig") as f: + loaded_config = json.load(f) + + assert loaded_config["enabled"] is False + assert loaded_config["nested"]["value"] == "b" + assert config.enabled is False + assert config.nested["value"] == "b" + def test_dot_notation_access(self, temp_config_path, minimal_default_config): """Test accessing config values using dot notation.""" config = AstrBotConfig( diff --git a/tests/unit/test_cron_manager.py b/tests/unit/test_cron_manager.py index 9d529bdbba..1723202099 100644 --- a/tests/unit/test_cron_manager.py +++ b/tests/unit/test_cron_manager.py @@ -8,12 +8,15 @@ import pytest +from astrbot.core.cron.events import CronMessageEvent from astrbot.core.cron.manager import ( CronJobManager, CronJobSchedulingError, _normalize_crontab_day_of_week, ) from astrbot.core.db.po import CronJob +from astrbot.core.platform.message_session import MessageSession +from astrbot.core.platform.message_type import MessageType @pytest.fixture @@ -828,3 +831,13 @@ def test_get_next_run_time_nonexistent(self, cron_manager): next_run = cron_manager._get_next_run_time("non-existent") assert next_run is None + + +def test_cron_message_event_stamps_explicit_surface(): + event = CronMessageEvent( + context=MagicMock(), + session=MessageSession("cron", MessageType.FRIEND_MESSAGE, "user-1"), + message="hello", + ) + + assert event.get_extra("explicit_surface") is True diff --git a/tests/unit/test_group_chat_context_wiring.py b/tests/unit/test_group_chat_context_wiring.py index f9667a5c35..c983f0cf6d 100644 --- a/tests/unit/test_group_chat_context_wiring.py +++ b/tests/unit/test_group_chat_context_wiring.py @@ -4,7 +4,7 @@ import pytest -from astrbot.api.message_components import Face, Json, Plain, Reply +from astrbot.api.message_components import Face, Json, Mention, Plain, Reply from astrbot.api.provider import Provider from astrbot.builtin_stars.astrbot.group_chat_context import GroupChatContext from astrbot.builtin_stars.astrbot.main import Main @@ -249,3 +249,151 @@ async def test_format_message_truncates_long_json_card_fields(): formatted = await context._format_message(event, {}) assert f"Description: {'a' * 200}..." in formatted + + +def _make_empty_mention_main(): + main = Main.__new__(Main) + main.context = MagicMock() + main.context.config.get.return_value = { + "platform_settings": { + "empty_mention_waiting": True, + "empty_mention_waiting_need_reply": True, + }, + "command_prefixes": ["/"], + } + main.context.conversations.current_id = AsyncMock(return_value="cid-1") + main.context.conversations.get = AsyncMock(return_value=None) + main.context.messages.wait_for = AsyncMock() + main.context.messages.submit = MagicMock() + main.group_chat_context = None + return main + + +@pytest.mark.asyncio +async def test_empty_mention_does_not_request_llm_or_resubmit(): + main = _make_empty_mention_main() + event = MagicMock() + event.unified_msg_origin = "aiocqhttp:GroupMessage:group" + event.get_messages.return_value = [Mention(target="bot")] + event.get_self_id.return_value = "bot" + event.request_llm = MagicMock() + event.message_obj.message = [Mention(target="bot")] + + results = [item async for item in main.handle_empty_mention(event)] + + assert results == [] + event.request_llm.assert_not_called() + main.context.messages.wait_for.assert_not_awaited() + main.context.messages.submit.assert_not_called() + + +@pytest.mark.asyncio +async def test_empty_mention_waiting_false_skips_prefix_only_wait(): + main = _make_empty_mention_main() + main.context.config.get.return_value = { + "platform_settings": { + "empty_mention_waiting": False, + "empty_mention_waiting_need_reply": True, + }, + "command_prefixes": ["/"], + } + event = MagicMock() + event.unified_msg_origin = "aiocqhttp:GroupMessage:group" + event.get_messages.return_value = [Plain("/")] + event.is_private_chat.return_value = False + event.request_llm = MagicMock() + + results = [item async for item in main.handle_empty_mention(event)] + + assert results == [] + event.request_llm.assert_not_called() + main.context.messages.wait_for.assert_not_awaited() + main.context.messages.submit.assert_not_called() + + +@pytest.mark.asyncio +async def test_prefix_only_wait_skips_when_group_llm_off(): + main = _make_empty_mention_main() + main.context.config.get.return_value = { + "platform_settings": { + "empty_mention_waiting": True, + "empty_mention_waiting_need_reply": True, + }, + "command_prefixes": ["/"], + "llm_access": {"group": "off", "private": "prefix"}, + } + event = MagicMock() + event.unified_msg_origin = "aiocqhttp:GroupMessage:group" + event.get_messages.return_value = [Plain("/")] + event.is_private_chat.return_value = False + event.request_llm = MagicMock() + + results = [item async for item in main.handle_empty_mention(event)] + + assert results == [] + event.request_llm.assert_not_called() + main.context.messages.wait_for.assert_not_awaited() + main.context.messages.submit.assert_not_called() + + +@pytest.mark.asyncio +async def test_prefix_only_wait_skips_when_private_llm_off(): + main = _make_empty_mention_main() + main.context.config.get.return_value = { + "platform_settings": { + "empty_mention_waiting": True, + "empty_mention_waiting_need_reply": True, + }, + "command_prefixes": ["/"], + "llm_access": {"group": "prefix", "private": "off"}, + } + event = MagicMock() + event.unified_msg_origin = "aiocqhttp:FriendMessage:user" + event.get_messages.return_value = [Plain("/")] + event.is_private_chat.return_value = True + event.request_llm = MagicMock() + + results = [item async for item in main.handle_empty_mention(event)] + + assert results == [] + event.request_llm.assert_not_called() + main.context.messages.wait_for.assert_not_awaited() + main.context.messages.submit.assert_not_called() + + +@pytest.mark.asyncio +async def test_command_prefix_only_still_waits_without_synthesizing_mention(): + main = _make_empty_mention_main() + waiter_holder: dict[str, object] = {} + + async def capture_wait(event, waiter, timeout_seconds=60): + _ = event + _ = timeout_seconds + waiter_holder["waiter"] = waiter + + main.context.messages.wait_for = capture_wait + event = MagicMock() + event.unified_msg_origin = "aiocqhttp:GroupMessage:group" + event.get_messages.return_value = [Plain("/")] + event.is_private_chat.return_value = False + event.get_self_id.return_value = "bot" + event.get_platform_id.return_value = "aiocqhttp" + event.request_llm = MagicMock(return_value="llm") + event.plain_result = MagicMock() + + results = [item async for item in main.handle_empty_mention(event)] + + assert results == ["llm"] + event.request_llm.assert_called_once() + waiter = waiter_holder["waiter"] + follow_up = MagicMock() + follow_up.message_str = "hello" + follow_up.message_obj.message = [Plain("hello")] + follow_up.get_self_id.return_value = "bot" + controller = MagicMock() + await waiter(controller, follow_up) + assert all(not isinstance(item, Mention) for item in follow_up.message_obj.message) + follow_up.set_extra.assert_called_once_with("explicit_surface", True) + main.context.messages.submit.assert_called_once() + follow_up.stop_event.assert_called_once() + controller.stop.assert_called_once() diff --git a/tests/unit/test_platform_manager.py b/tests/unit/test_platform_manager.py index cecc1c0a6f..f885332eb9 100644 --- a/tests/unit/test_platform_manager.py +++ b/tests/unit/test_platform_manager.py @@ -812,11 +812,25 @@ def test_platform_manager_create_event_falls_back_to_platform_name() -> None: platform.create_event.assert_called_once() platform.commit_event.assert_called_once() platform.commit_event.call_args.args[0].set_extra.assert_called_once_with( - "adapter_preconfigured", + "explicit_surface", False, ) +def test_platform_manager_create_event_stamps_explicit_surface_when_wake() -> None: + manager = _make_manager() + platform = MagicMock() + platform.create_event.return_value = MagicMock() + manager._find_inst_by_id = MagicMock(return_value=platform) + + manager.create_event("telegram", MagicMock(), is_wake=True) + + platform.commit_event.call_args.args[0].set_extra.assert_called_once_with( + "explicit_surface", + True, + ) + + def test_platform_discovery_imports_registered_builtin_adapter_once(monkeypatch): adapter_type = "test-adapter" module_name = "astrbot.core.platform.sources.test_adapter" diff --git a/tests/unit/test_turn_router.py b/tests/unit/test_turn_router.py index 9402077f70..3f44375d69 100644 --- a/tests/unit/test_turn_router.py +++ b/tests/unit/test_turn_router.py @@ -6,10 +6,11 @@ CommandGroupRegistration, CommandResolutionKind, ) -from astrbot.core.message.components import Mention, MentionAll, Plain +from astrbot.core.message.components import Mention, MentionAll, Plain, Reply from astrbot.core.pipeline.turn_router import ( LlmAccess, TurnRouteInput, + llm_access_from_config, longest_prefix_match, public_root_token, route_turn, @@ -138,8 +139,8 @@ def test_llm_status_is_command_and_chat_is_not(): assert chat.should_run_llm is True -def test_group_mention_still_matches_help(): - llm = LlmAccess(group="mention") +def test_group_prefix_still_matches_help(): + llm = LlmAccess(group="prefix") result = route_turn(_input("/help", private=False, llm=llm)) assert result.should_run_command is True assert result.should_run_llm is False @@ -236,3 +237,131 @@ def test_mention_all_first_does_not_block_command(): assert result.should_run_command is True assert result.should_run_llm is False assert result.stop is False + + +def test_default_private_hello_does_not_run_llm(): + result = route_turn(_input("hello")) + assert result.should_run_llm is False + assert result.should_run_command is False + assert result.stop is True + + +def test_private_unmatched_prefix_runs_llm(): + result = route_turn(_input("/hello")) + assert result.should_run_llm is True + assert result.should_run_command is False + assert result.message_str == "hello" + assert "llm_prefix" in result.wake_reasons + + +def test_bare_command_prefix_does_not_run_llm(): + result = route_turn(_input("/")) + assert result.should_run_llm is False + assert result.should_run_command is False + assert result.stop is True + + +def test_bare_group_command_prefix_does_not_run_llm(): + result = route_turn(_input("/", private=False)) + assert result.should_run_llm is False + assert result.stop is True + + +def test_bare_llm_prefix_without_payload_does_not_run_llm(): + result = route_turn(_input("#", llm=LlmAccess(prefixes=("#",)))) + assert result.should_run_llm is False + assert result.stop is True + + +def test_group_mention_all_does_not_admit_plain(): + result = route_turn( + _input( + "hello", + private=False, + messages=[MentionAll(), Plain("hello")], + ) + ) + assert result.should_run_llm is False + assert result.stop is True + + +def test_legacy_group_mention_config_falls_back_to_prefix(): + policy = llm_access_from_config({"llm_access": {"group": "mention"}}) + assert policy.group == "prefix" + also_legacy = llm_access_from_config({"llm_access": {"group": "prefix_or_mention"}}) + assert also_legacy.group == "prefix" + + +def test_explicit_surface_admits_group_plain(): + result = route_turn(_input("hello", private=False, explicit_surface=True)) + assert result.should_run_llm is True + assert "explicit_surface" in result.wake_reasons + + +def test_open_window_admits_when_group_off(): + result = route_turn( + _input("hello", private=False, llm=LlmAccess(group="off"), has_open_window=True) + ) + assert result.should_run_llm is True + assert "turn_continuation" in result.wake_reasons + + +def test_reply_to_bot_admits_group_even_when_off(): + result = route_turn( + _input( + "hello", + private=False, + llm=LlmAccess(group="off", reply_to_bot=True), + messages=[Reply(id="1", sender_id="bot"), Plain("hello")], + ) + ) + assert result.should_run_llm is True + assert "reply_to_bot" in result.wake_reasons + + +def test_explicit_surface_webchat_hello_runs_llm(): + result = route_turn(_input("hello", explicit_surface=True)) + assert result.should_run_llm is True + assert result.should_run_command is False + assert result.message_str == "hello" + + +def test_explicit_surface_webchat_help_is_command(): + result = route_turn(_input("/help", explicit_surface=True)) + assert result.should_run_command is True + assert result.should_run_llm is False + + +def test_explicit_surface_does_not_admit_when_group_off(): + result = route_turn( + _input( + "hello", + private=False, + llm=LlmAccess(group="off"), + explicit_surface=True, + ) + ) + assert result.should_run_llm is False + assert result.stop is True + + +def test_explicit_surface_does_not_admit_when_private_off(): + result = route_turn( + _input("hello", llm=LlmAccess(private="off"), explicit_surface=True) + ) + assert result.should_run_llm is False + assert result.stop is True + + +def test_explicit_surface_off_still_continues_open_window(): + result = route_turn( + _input( + "hello", + private=False, + llm=LlmAccess(group="off"), + explicit_surface=True, + has_open_window=True, + ) + ) + assert result.should_run_llm is True + assert "turn_continuation" in result.wake_reasons diff --git a/tests/unit/test_turn_routing_current.py b/tests/unit/test_turn_routing_current.py index 2f878e5a66..f588cf2cf7 100644 --- a/tests/unit/test_turn_routing_current.py +++ b/tests/unit/test_turn_routing_current.py @@ -54,8 +54,8 @@ async def status(self, event) -> None: ... @pytest.mark.parametrize( ("settings", "text", "expected_wake"), [ - ({}, "今天天气", True), - ({"llm_access": {"private": "prefix"}}, "今天天气", False), + ({}, "今天天气", False), + ({"llm_access": {"private": "open"}}, "今天天气", True), ({"llm_access": {"private": "prefix"}}, "/今天天气", True), ], ) @@ -87,7 +87,7 @@ async def test_private_extra_token_chat_leaves_slash_and_bare_forms(): assert (await stage._detect_wake(slash)).should_wake is True assert slash.message_str == "chat 今天天气" - assert (await stage._detect_wake(bare)).should_wake is True + assert (await stage._detect_wake(bare)).should_wake is False assert bare.message_str == "chat 今天天气" diff --git a/tests/unit/test_waking_check_stage.py b/tests/unit/test_waking_check_stage.py index d9cbbaf857..b88561cc9e 100644 --- a/tests/unit/test_waking_check_stage.py +++ b/tests/unit/test_waking_check_stage.py @@ -18,6 +18,10 @@ from astrbot.core.runtime_catalogs import RuntimeCatalogs from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command_group import CommandGroupFilter +from astrbot.core.star.filter.event_message_type import ( + EventMessageType, + EventMessageTypeFilter, +) from astrbot.core.star.filter.permission import ActionPermissionFilter from astrbot.core.star.star import StarMetadata from astrbot.core.star.star_handler import EventType, StarHandlerMetadata @@ -120,6 +124,29 @@ async def send(self, payload): self.sent.append(payload) +def test_default_llm_access_private_is_prefix(): + from astrbot.core.config.default import ( + CONFIG_METADATA_2, + CONFIG_METADATA_3, + DEFAULT_CONFIG, + ) + + assert DEFAULT_CONFIG["llm_access"]["private"] == "prefix" + expected = ["open", "prefix", "off"] + assert ( + CONFIG_METADATA_2["misc_config_group"]["metadata"]["llm_access"]["items"][ + "group" + ]["options"] + == expected + ) + assert ( + CONFIG_METADATA_3["platform_group"]["metadata"]["general"]["items"][ + "llm_access.group" + ]["options"] + == expected + ) + + async def make_stage(**settings): platform_settings = { "no_permission_reply": True, @@ -129,7 +156,7 @@ async def make_stage(**settings): } llm_access = { "prefixes": ["/"], - "private": "open", + "private": "prefix", "group": "prefix", "reply_to_bot": False, } @@ -344,11 +371,11 @@ def make_command_handler(name: str, handler, *extra_filters): @pytest.mark.parametrize( ("settings", "event", "expected"), [ - ({}, FakeEvent([Plain("hello")], private=True), True), + ({}, FakeEvent([Plain("hello")], private=True), False), ( - {"llm_access": {"private": "prefix"}}, + {"llm_access": {"private": "open"}}, FakeEvent([Plain("hello")], private=True), - False, + True, ), ( {"llm_access": {"private": "prefix"}}, @@ -366,16 +393,20 @@ def make_command_handler(name: str, handler, *extra_filters): ( {"llm_access": {"group": "mention"}}, FakeEvent([Mention(target="bot"), Plain("hello")]), - True, + False, ), ({}, FakeEvent([Mention(target="other"), Plain("hello")]), False), - ({}, FakeEvent([MentionAll(), Plain("hello")]), True), - ({"ignore_at_all": True}, FakeEvent([MentionAll(), Plain("hello")]), False), + ({}, FakeEvent([MentionAll(), Plain("hello")]), False), ( {"llm_access": {"group": "mention"}}, FakeEvent([Mention(target="all"), Plain("hello")]), False, ), + ( + {"llm_access": {"group": "off"}}, + FakeEvent([Plain("hello")], extras={"explicit_surface": True}), + False, + ), ], ) async def test_detect_wake_behavior_matrix(settings, event, expected): @@ -411,16 +442,77 @@ async def test_unwoken_mention_does_not_mutate_at_component(monkeypatch): @pytest.mark.asyncio -async def test_adapter_preconfigured_wake_bypasses_group_llm_access(monkeypatch): +async def test_explicit_surface_wakes_group_mention_only(monkeypatch): stage = await make_stage() install_handlers(stage, monkeypatch, []) event = FakeEvent([Mention(target="bot")]) - event.set_extra("adapter_preconfigured", True) + event.set_extra("explicit_surface", True) + + await stage.process(event) + + assert event.stopped is False + assert event.get_extra("should_run_llm") is True + assert "explicit_surface" in event.get_extra("wake_reasons") + + +@pytest.mark.asyncio +async def test_explicit_surface_admits_plain_hello_under_default_prefix(monkeypatch): + stage = await make_stage() + install_handlers(stage, monkeypatch, []) + event = FakeEvent([Plain("hello")], message_text="hello") + event.set_extra("explicit_surface", True) + + await stage.process(event) + + assert event.stopped is False + assert event.get_extra("should_run_llm") is True + assert event.get_extra("should_run_command") is False + assert "explicit_surface" in event.get_extra("wake_reasons") + + +@pytest.mark.asyncio +async def test_bare_command_prefix_does_not_run_llm(monkeypatch): + stage = await make_stage() + install_handlers(stage, monkeypatch, []) + event = FakeEvent([Plain("/")], message_text="/") + + await stage.process(event) + + assert event.get_extra("should_run_llm") is False + assert event.stopped is True + + +@pytest.mark.asyncio +async def test_default_private_hello_does_not_run_llm(monkeypatch): + stage = await make_stage() + install_handlers(stage, monkeypatch, []) + event = FakeEvent([Plain("hello")], private=True) + + decision = await stage._detect_wake(event) + + assert decision.should_wake is False + assert event.get_extra("should_run_llm") is False + + +@pytest.mark.asyncio +async def test_event_message_type_all_still_activates_when_llm_dropped(monkeypatch): + stage = await make_stage() + handler = StarHandlerMetadata( + EventType.AdapterMessageEvent, + "test.plugin_all", + "on_all", + "test.plugin", + lambda *_args: None, + [EventMessageTypeFilter(EventMessageType.ALL)], + ) + install_handlers(stage, monkeypatch, [handler]) + event = FakeEvent([Plain("hello")], private=True) await stage.process(event) + assert event.get_extra("should_run_llm") is False + assert event.get_extra("activated_handlers") == [handler] assert event.stopped is False - assert "adapter_preconfigured" in event.get_extra("wake_reasons") @pytest.mark.asyncio diff --git a/tests/unit/test_webchat_message_parts.py b/tests/unit/test_webchat_message_parts.py index ce9ec0e71a..bd67eebdce 100644 --- a/tests/unit/test_webchat_message_parts.py +++ b/tests/unit/test_webchat_message_parts.py @@ -182,3 +182,5 @@ def test_webchat_create_event_does_not_promote_declared_username(tmp_path): event = adapter.create_event(message) assert event.platform_member_role == "member" assert event.platform_role_source == "none" + assert event.get_extra("explicit_surface") is True + assert event.message_str == "hello"