diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json index d166da16af..26bc5cb122 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/en-US.json @@ -12,8 +12,9 @@ "session.unwatch.ok": "Watch stopped.", "session.unwatch.missing": "No matching watch owned by you.", "session.unwatch.usage": "Usage: /session unwatch [listener|this] . Omit the listener or write this for the current session.", - "session.watches.empty": "You have no active watches in this session.", + "session.watches.empty": "You have no active watches in {source}.", "session.watches.body": "Your active watches:\n{watches}", + "session.watches.usage": "Usage: /session watches [listener|this]. Omit the listener or write this for the current session.", "session.send.invalid": "Usage: /send . Keep the command header in the message text.", "session.send.accepted": "The target platform accepted the message.", "session.send.partial": "Only part of the message was accepted. Resending may create duplicates.", @@ -55,10 +56,12 @@ "bot.leave.ok": "Leaving the current group.", "bot.leave.failed": "Failed to leave the group.", "session.info.body": "UMO: {umo}\nUID: {user_id}\nSubject ID: {subject_id}\nUse UMO to set whitelist and configure routing. Use UID to set the admin list. Use Subject ID to bind roles on the authorization page.\n\nYour session information:\nBot ID: {platform_id}\nMessage Type: {message_type}\nSession ID: {session_id}{group_note}", + "session.info.target": "UMO: {umo}\nAuto name: {auto_name}\nAlias: {alias}\n\nSession information:\nBot ID: {platform_id}\nMessage Type: {message_type}\nSession ID: {session_id}", "session.info.group": "\n\nGroup ID: {group_id}. Set this ID to whitelist to allow the entire group.", "session.name.empty": "(empty)", - "session.name.usage": "Usage: /session name \nUMO: {umo}\nAuto name: {auto_name}\nAlias: {alias}", + "session.name.usage": "Usage: /session name [--target UMO] \nClear: /session name [--target UMO] --clear\nUMO: {umo}\nAuto name: {auto_name}\nAlias: {alias}", "session.name.set": "UMO name set to: {alias}\nUMO: {umo}", + "session.name.cleared": "UMO alias cleared.\nUMO: {umo}", "conversation.reset.ok": "Conversation reset.", "conversation.reset.no_provider": "Cannot find any LLM provider. Configure one first.", "conversation.reset.no_conversation": "You are not in a conversation. Use /conversation create to create one.", @@ -98,6 +101,10 @@ "chat.status.disabled": "LLM chat is disabled for the current session.", "chat.set.enabled": "LLM chat is now enabled for the current session.", "chat.set.disabled": "LLM chat is now disabled for the current session.", + "tts.status.enabled": "TTS is enabled for the current session.", + "tts.status.disabled": "TTS is disabled for the current session.", + "tts.set.enabled": "TTS is now enabled for the current session.", + "tts.set.disabled": "TTS is now disabled for the current session.", "flow.mode.unset": "unset (follow global)", "flow.mode.on": "on", "flow.mode.off": "off", diff --git a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json index 15d4a0230c..9ce03d1ed1 100644 --- a/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json +++ b/astrbot/builtin_stars/builtin_commands/.astrbot-plugin/i18n/zh-CN.json @@ -12,8 +12,9 @@ "session.unwatch.ok": "已停止监听。", "session.unwatch.missing": "没有找到属于你的该项监听。", "session.unwatch.usage": "用法:/session unwatch [监听会话|this] <被监听会话>。监听会话可省略或写 this 表示当前会话。", - "session.watches.empty": "你在当前会话没有有效的监听。", + "session.watches.empty": "你在 {source} 没有有效的监听。", "session.watches.body": "你的有效监听:\n{watches}", + "session.watches.usage": "用法:/session watches [监听会话|this]。监听会话可省略或写 this 表示当前会话。", "session.send.invalid": "用法:/send <文字或附件>。请保留消息文字中的指令头。", "session.send.accepted": "目标平台已接受消息。", "session.send.partial": "目标平台仅接受了部分消息,重新发送可能造成重复。", @@ -55,10 +56,12 @@ "bot.leave.ok": "正在退出当前群。", "bot.leave.failed": "退群失败。", "session.info.body": "UMO:{umo}\nUID:{user_id}\n授权主体:{subject_id}\nUMO 用于设置白名单和配置路由,UID 用于设置管理员列表,授权主体用于权限页绑定。\n\n当前会话信息:\nBot ID:{platform_id}\n消息类型:{message_type}\n会话 ID:{session_id}{group_note}", + "session.info.target": "UMO:{umo}\n自动名称:{auto_name}\n别名:{alias}\n\n会话信息:\nBot ID:{platform_id}\n消息类型:{message_type}\n会话 ID:{session_id}", "session.info.group": "\n\n群 ID:{group_id}。将该 ID 加入白名单即可允许整个群。", "session.name.empty": "(空)", - "session.name.usage": "用法:/session name <名称>\nUMO:{umo}\n自动名称:{auto_name}\n别名:{alias}", + "session.name.usage": "用法:/session name [--target UMO] <名称>\n清空:/session name [--target UMO] --clear\nUMO:{umo}\n自动名称:{auto_name}\n别名:{alias}", "session.name.set": "UMO 名称已设为:{alias}\nUMO:{umo}", + "session.name.cleared": "已清除 UMO 别名。\nUMO:{umo}", "conversation.reset.ok": "对话已重置。", "conversation.reset.no_provider": "未找到 LLM Provider。请先配置。", "conversation.reset.no_conversation": "当前不在对话中。使用 /conversation create 创建。", @@ -98,6 +101,10 @@ "chat.status.disabled": "当前会话已停用 LLM 聊天。", "chat.set.enabled": "当前会话的 LLM 聊天已启用。", "chat.set.disabled": "当前会话的 LLM 聊天已停用。", + "tts.status.enabled": "当前会话已启用 TTS。", + "tts.status.disabled": "当前会话已停用 TTS。", + "tts.set.enabled": "当前会话的 TTS 已启用。", + "tts.set.disabled": "当前会话的 TTS 已停用。", "flow.mode.unset": "未设置(跟随全局)", "flow.mode.on": "开启", "flow.mode.off": "关闭", diff --git a/astrbot/builtin_stars/builtin_commands/commands/__init__.py b/astrbot/builtin_stars/builtin_commands/commands/__init__.py index d46e2deca5..6db1ade50f 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/__init__.py +++ b/astrbot/builtin_stars/builtin_commands/commands/__init__.py @@ -10,6 +10,7 @@ from .plugin import PluginCommands from .provider import ProviderCommands from .session import SessionCommands +from .tts import TtsCommands from .variable import VariableCommands from .work import WorkCommands @@ -24,6 +25,7 @@ "PluginCommands", "ProviderCommands", "SessionCommands", + "TtsCommands", "VariableCommands", "WorkCommands", ] diff --git a/astrbot/builtin_stars/builtin_commands/commands/bot.py b/astrbot/builtin_stars/builtin_commands/commands/bot.py index 0ded89decb..63b844c8b6 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/bot.py +++ b/astrbot/builtin_stars/builtin_commands/commands/bot.py @@ -2,6 +2,7 @@ from astrbot.api.event import AstrMessageEvent from .reply import reply_i18n, send_i18n +from .target import resolve_target_umo _SESSION_SERVICE_CONFIG = "session_service_config" @@ -30,27 +31,12 @@ async def _resolve_target( *, action: str, ) -> str | None: - target = target.strip() - if not target: - return event.unified_msg_origin - if len(target.split()) != 1: - await reply_i18n(self.context, event, "bot.target.usage") - return None - if target.lower() == "this": - return event.unified_msg_origin - try: - decision = await self.context.authz.authorize_target_session( - event, - action=action, - umo=target, - ) - except PermissionError, ValueError: - await reply_i18n(self.context, event, "bot.target.denied") - return None - if not decision.allowed: - await reply_i18n(self.context, event, "bot.target.denied") - return None - return target + return await resolve_target_umo( + self.context, + event, + target, + action=action, + ) async def status(self, event: AstrMessageEvent, target: str = "") -> None: """Show bot switches for the current or an explicitly selected session.""" diff --git a/astrbot/builtin_stars/builtin_commands/commands/session.py b/astrbot/builtin_stars/builtin_commands/commands/session.py index c49716820d..ac15890abf 100644 --- a/astrbot/builtin_stars/builtin_commands/commands/session.py +++ b/astrbot/builtin_stars/builtin_commands/commands/session.py @@ -3,6 +3,7 @@ from astrbot.api.platform import MAX_WATCH_TTL_SECONDS, MIN_WATCH_TTL_SECONDS from .reply import reply_i18n +from .target import resolve_target_umo def _resolve_listener(token: str, current_umo: str) -> str: @@ -37,6 +38,16 @@ def parse_unwatch_spec(spec: str, current_umo: str) -> tuple[str, str]: return _resolve_listener(parts[0], current_umo), parts[1] +def parse_watches_spec(spec: str, current_umo: str) -> str: + """Parse `/session watches [listener|this]`.""" + parts = spec.split() + if not parts: + return current_umo + if len(parts) > 1: + raise ValueError("Invalid watch arguments") + return _resolve_listener(parts[0], current_umo) + + def _authorization_subject_id(event: AstrMessageEvent) -> str: """Return the authorization subject id for Dashboard binding import.""" attached = getattr(event, "subject", None) @@ -59,9 +70,47 @@ class SessionCommands: def __init__(self, context: star.PluginContext) -> None: self.context = context - async def info(self, event: AstrMessageEvent) -> None: - """Show identifiers and metadata for the current session.""" - umo = event.unified_msg_origin + async def _auto_name(self, event: AstrMessageEvent, umo: str) -> str: + if umo == event.unified_msg_origin: + return self.context.sessions.auto_name(event) + saved = await self.context.sessions.alias(umo) + return self.context.sessions.normalize_name( + saved.auto_name if saved else "", + ) + + async def info(self, event: AstrMessageEvent, target: str = "") -> None: + """Show identifiers and metadata for the current or selected session.""" + umo = await resolve_target_umo( + self.context, + event, + target, + action="session.read_target", + ) + if umo is None: + return + if umo != event.unified_msg_origin: + parsed = self.context.sessions.parse(umo) + saved = await self.context.sessions.alias(umo) + empty = await self.context.i18n.t(event, "session.name.empty") + await reply_i18n( + self.context, + event, + "session.info.target", + umo=umo, + auto_name=self.context.sessions.normalize_name( + saved.auto_name if saved else "", + ) + or empty, + alias=self.context.sessions.normalize_name( + saved.user_alias if saved else "", + ) + or empty, + platform_id=parsed["platform"], + message_type=parsed["message_type"], + session_id=parsed["session_id"], + ) + return + group_id = event.get_group_id() unique_session = bool( self.context.config.get()["platform_settings"]["unique_session"] @@ -86,12 +135,43 @@ async def info(self, event: AstrMessageEvent) -> None: group_note=group_note, ) - async def name(self, event: AstrMessageEvent, alias: str) -> None: - """Show or set the display name for the current session.""" - umo = event.unified_msg_origin - auto_name = self.context.sessions.auto_name(event) + async def name( + self, + event: AstrMessageEvent, + alias: str, + target: str = "", + *, + clear: bool = False, + ) -> None: + """Show, set, or clear the display name for a session.""" alias = self.context.sessions.normalize_name(alias) + if clear and alias: + await reply_i18n(self.context, event, "session.name.usage") + return + umo = await resolve_target_umo( + self.context, + event, + target, + action="session.manage_target", + ) + if umo is None: + return + auto_name = await self._auto_name(event, umo) empty = await self.context.i18n.t(event, "session.name.empty") + if clear: + await self.context.sessions.set_alias( + umo=umo, + creator_sender_id=str(event.get_sender_id() or ""), + auto_name=auto_name, + user_alias=None, + ) + await reply_i18n( + self.context, + event, + "session.name.cleared", + umo=umo, + ) + return if not alias: saved_alias = await self.context.sessions.alias(umo) user_alias = self.context.sessions.normalize_name( @@ -181,10 +261,14 @@ async def unwatch(self, event: AstrMessageEvent, spec: str) -> None: "session.unwatch.ok" if removed else "session.unwatch.missing", ) - async def watches(self, event: AstrMessageEvent) -> None: - """List this actor's active watches in the current session.""" + async def watches(self, event: AstrMessageEvent, spec: str = "") -> None: + """List this actor's active watches for a listener session.""" try: - items = await self.context.bridges.list(event) + source_umo = parse_watches_spec(spec, event.unified_msg_origin) + items = await self.context.bridges.list(event, source_umo=source_umo) + except ValueError: + await reply_i18n(self.context, event, "session.watches.usage") + return except PermissionError: await reply_i18n(self.context, event, "session.bridge.denied") return @@ -192,8 +276,10 @@ async def watches(self, event: AstrMessageEvent) -> None: self.context, event, "session.watches.body" if items else "session.watches.empty", + source=source_umo, watches="\n".join( - f"{item.target_umo} ({item.remaining_seconds}s)" for item in items + f"{item.source_umo} -> {item.target_umo} ({item.remaining_seconds}s)" + for item in items ), ) diff --git a/astrbot/builtin_stars/builtin_commands/commands/target.py b/astrbot/builtin_stars/builtin_commands/commands/target.py new file mode 100644 index 0000000000..12d4fb8eb7 --- /dev/null +++ b/astrbot/builtin_stars/builtin_commands/commands/target.py @@ -0,0 +1,35 @@ +from astrbot.api import star +from astrbot.api.event import AstrMessageEvent + +from .reply import reply_i18n + + +async def resolve_target_umo( + context: star.PluginContext, + event: AstrMessageEvent, + target: str, + *, + action: str, +) -> str | None: + """Return the current or explicitly selected session UMO.""" + target = target.strip() + if not target: + return event.unified_msg_origin + if len(target.split()) != 1: + await reply_i18n(context, event, "bot.target.usage") + return None + if target.lower() == "this": + return event.unified_msg_origin + try: + decision = await context.authz.authorize_target_session( + event, + action=action, + umo=target, + ) + except PermissionError, ValueError: + await reply_i18n(context, event, "bot.target.denied") + return None + if not decision.allowed: + await reply_i18n(context, event, "bot.target.denied") + return None + return target diff --git a/astrbot/builtin_stars/builtin_commands/commands/tts.py b/astrbot/builtin_stars/builtin_commands/commands/tts.py new file mode 100644 index 0000000000..806d939e11 --- /dev/null +++ b/astrbot/builtin_stars/builtin_commands/commands/tts.py @@ -0,0 +1,49 @@ +from astrbot.api import star +from astrbot.api.event import AstrMessageEvent + +from .reply import reply_i18n + + +class TtsCommands: + def __init__(self, context: star.PluginContext) -> None: + self.context = context + + async def status(self, event: AstrMessageEvent) -> None: + """Show the TTS state for the current session.""" + umo = event.unified_msg_origin + settings = await self.context.preferences.session_get( + umo, + "session_service_config", + {}, + ) + enabled = settings.get("tts_enabled", True) + await reply_i18n( + self.context, + event, + "tts.status.enabled" if enabled else "tts.status.disabled", + ) + + async def set_enabled( + self, + event: AstrMessageEvent, + enabled: bool, + ) -> None: + """Set the TTS state for the current session.""" + umo = event.unified_msg_origin + settings = await self.context.preferences.session_get( + umo, + "session_service_config", + {}, + ) + settings = dict(settings or {}) + settings["tts_enabled"] = enabled + await self.context.preferences.session_put( + umo, + "session_service_config", + settings, + ) + await reply_i18n( + self.context, + event, + "tts.set.enabled" if enabled else "tts.set.disabled", + ) diff --git a/astrbot/builtin_stars/builtin_commands/main.py b/astrbot/builtin_stars/builtin_commands/main.py index 697da3c976..76bc842bc1 100644 --- a/astrbot/builtin_stars/builtin_commands/main.py +++ b/astrbot/builtin_stars/builtin_commands/main.py @@ -15,6 +15,7 @@ PluginCommands, ProviderCommands, SessionCommands, + TtsCommands, VariableCommands, WorkCommands, ) @@ -34,6 +35,7 @@ def __init__(self, context: star.PluginContext) -> None: self.plugin_c = PluginCommands(self.context) self.provider_c = ProviderCommands(self.context) self.session_c = SessionCommands(self.context) + self.tts_c = TtsCommands(self.context) self.variable_c = VariableCommands(self.context) self.work_c = WorkCommands(self.context) @@ -90,17 +92,23 @@ def session(self) -> None: @filter.permission("session.read") @session.command("info") - async def session_info(self, event: AstrMessageEvent) -> None: - """Show IDs and metadata for the current session""" - await self.session_c.info(event) + async def session_info( + self, event: AstrMessageEvent, target: GreedyStr = GreedyStr("") + ) -> None: + """Show IDs and metadata for the current or selected session""" + await self.session_c.info(event, target) @filter.permission("session.manage") @session.command("name") async def session_name( - self, event: AstrMessageEvent, alias: GreedyStr = GreedyStr("") + self, + event: AstrMessageEvent, + alias: GreedyStr = GreedyStr(""), + target: Annotated[str, option("--target", "-t")] = "", + clear: Annotated[bool, option("--clear", "-c")] = False, ) -> None: - """Show or set the display name for the current session""" - await self.session_c.name(event, alias) + """Show, set, or clear the display name for a session""" + await self.session_c.name(event, alias, target, clear=clear) @filter.permission("session.watch") @session.command("watch") @@ -120,9 +128,11 @@ async def session_unwatch( @filter.permission("session.read") @session.command("watches") - async def session_watches(self, event: AstrMessageEvent) -> None: + async def session_watches( + self, event: AstrMessageEvent, spec: GreedyStr = GreedyStr("") + ) -> None: """List active cross-session watches.""" - await self.session_c.watches(event) + await self.session_c.watches(event, spec) @filter.permission("session.block") @session.command("block") @@ -331,6 +341,28 @@ async def llm_disable(self, event: AstrMessageEvent) -> None: """Disable LLM chat for the current session""" await self.chat_c.set_enabled(event, False) + @filter.command_group("tts") + def tts(self) -> None: + """Manage TTS for the current session""" + + @filter.permission("session.manage") + @tts.command("status") + async def tts_status(self, event: AstrMessageEvent) -> None: + """Show whether TTS is enabled""" + await self.tts_c.status(event) + + @filter.permission("session.manage") + @tts.command("enable") + async def tts_enable(self, event: AstrMessageEvent) -> None: + """Enable TTS for the current session""" + await self.tts_c.set_enabled(event, True) + + @filter.permission("session.manage") + @tts.command("disable") + async def tts_disable(self, event: AstrMessageEvent) -> None: + """Disable TTS for the current session""" + await self.tts_c.set_enabled(event, False) + @filter.command_group("flow") def flow(self) -> None: """Manage session streaming override""" diff --git a/astrbot/core/platform/session_bridge.py b/astrbot/core/platform/session_bridge.py index 49f6cc05ca..99d631feee 100644 --- a/astrbot/core/platform/session_bridge.py +++ b/astrbot/core/platform/session_bridge.py @@ -190,14 +190,20 @@ async def unwatch( task.cancel() return removed - async def list_watches(self, event: AstrMessageEvent) -> tuple[SessionWatch, ...]: + async def list_watches( + self, + event: AstrMessageEvent, + *, + source_umo: str | None = None, + ) -> tuple[SessionWatch, ...]: subject, _ = self._actor(event) + listener = (source_umo or event.unified_msg_origin).strip() async with self._lock: expired = self._purge(monotonic()) items = tuple( grant.watch for key, grant in self._watches.items() - if key[:2] == (subject.id, event.unified_msg_origin) + if key[:2] == (subject.id, listener) ) await self._notify_expired_watches(expired) return items diff --git a/astrbot/core/star/plugin_context.py b/astrbot/core/star/plugin_context.py index 46a2443641..35f586aa54 100644 --- a/astrbot/core/star/plugin_context.py +++ b/astrbot/core/star/plugin_context.py @@ -193,9 +193,14 @@ async def unwatch( """Remove one watch owned by the event's trusted actor.""" return await self._manager.unwatch(event, target_umo, source_umo=source_umo) - async def list(self, event: AstrMessageEvent) -> tuple[SessionWatch, ...]: + async def list( + self, + event: AstrMessageEvent, + *, + source_umo: str | None = None, + ) -> tuple[SessionWatch, ...]: """List active watches owned by the event's trusted actor.""" - return await self._manager.list_watches(event) + return await self._manager.list_watches(event, source_umo=source_umo) async def send(self, event: AstrMessageEvent, target_umo: str) -> DeliveryReceipt: """Authorize and send the command body, attachments, and quote.""" @@ -959,6 +964,12 @@ def normalize_name(self, name: Any) -> str: return normalize_umo_name(name) + def parse(self, umo: str) -> dict[str, str]: + """Split a UMO into platform, message type, and session id.""" + from astrbot.core.umo_alias import parse_umo + + return parse_umo(umo) + async def set_alias( self, *, diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py index 7bf0add5fd..7a0e67458d 100644 --- a/astrbot/dashboard/schemas.py +++ b/astrbot/dashboard/schemas.py @@ -834,6 +834,7 @@ class BatchSessionServiceRequest(UmoListRequest): session_enabled: bool | None = None llm_enabled: bool | None = None tts_enabled: bool | None = None + session_blocked: bool | None = None class SessionGroupRequest(OpenModel): diff --git a/astrbot/dashboard/services/session_management_service.py b/astrbot/dashboard/services/session_management_service.py index b91410e87c..8b2cda8bbb 100644 --- a/astrbot/dashboard/services/session_management_service.py +++ b/astrbot/dashboard/services/session_management_service.py @@ -12,7 +12,12 @@ from astrbot.core.provider.entities import ProviderType from astrbot.core.provider.manager import ProviderManager from astrbot.core.star.star import PluginRegistry -from astrbot.core.umo_alias import build_umo_alias_map, parse_umo, serialize_umo_alias +from astrbot.core.umo_alias import ( + build_umo_alias_map, + normalize_umo_name, + parse_umo, + serialize_umo_alias, +) from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.utils.error_redaction import safe_error from astrbot.core.utils.shared_preferences import SharedPreferences @@ -123,6 +128,30 @@ def _session_config_name(config: dict) -> str: custom_name = config.get("custom_name", "") return custom_name if isinstance(custom_name, str) else "" + @staticmethod + def _session_config_blocked(config: dict) -> bool: + blocked = config.get("session_blocked") + return blocked if isinstance(blocked, bool) else False + + async def _sync_display_alias(self, umo: str, config: object) -> dict: + """Move a Dashboard display name onto the UMO alias store.""" + if not isinstance(config, dict): + raise SessionManagementServiceError( + "规则 session_service_config 需要对象类型的 rule_value" + ) + if "custom_name" not in config: + return config + stored = dict(config) + user_alias = normalize_umo_name(stored.pop("custom_name")) or None + existing = await self.db_helper.get_umo_alias(umo) + await self.db_helper.upsert_umo_alias( + umo=umo, + creator_sender_id=existing.creator_sender_id if existing else "", + auto_name=existing.auto_name if existing else None, + user_alias=user_alias, + ) + return stored + @staticmethod def _session_config_enabled(config: dict, key: str) -> bool: value = config.get(key, True) @@ -361,6 +390,8 @@ async def update_session_rule(self, data: object) -> dict: if rule_key == "session_plugin_config": rule_value = {umo: rule_value} + elif rule_key == "session_service_config": + rule_value = await self._sync_display_alias(umo, rule_value) provider_type = self._provider_type_from_rule_key(rule_key) if provider_type is not None: @@ -505,12 +536,15 @@ async def list_all_umos_with_status( if not isinstance(svc_config, dict): svc_config = {} - custom_name = self._session_config_name(svc_config) + custom_name = umo_info["user_alias"] or self._session_config_name( + svc_config + ) session_enabled = self._session_config_enabled( svc_config, "session_enabled" ) llm_enabled = self._session_config_enabled(svc_config, "llm_enabled") tts_enabled = self._session_config_enabled(svc_config, "tts_enabled") + session_blocked = self._session_config_blocked(svc_config) if search: search_lower = search.lower() @@ -539,6 +573,7 @@ async def list_all_umos_with_status( "session_enabled": session_enabled, "llm_enabled": llm_enabled, "tts_enabled": tts_enabled, + "session_blocked": session_blocked, "has_rules": umo in umo_rules, "chat_provider": rules.get(chat_provider_key), "tts_provider": rules.get(tts_provider_key), @@ -576,8 +611,14 @@ async def batch_update_service(self, data: object) -> dict: llm_enabled = payload.get("llm_enabled") tts_enabled = payload.get("tts_enabled") session_enabled = payload.get("session_enabled") + session_blocked = payload.get("session_blocked") - if llm_enabled is None and tts_enabled is None and session_enabled is None: + if ( + llm_enabled is None + and tts_enabled is None + and session_enabled is None + and session_blocked is None + ): raise SessionManagementServiceError("至少需要指定一个要修改的状态") if scope and not umos: @@ -604,6 +645,8 @@ async def batch_update_service(self, data: object) -> dict: session_config["tts_enabled"] = tts_enabled if session_enabled is not None: session_config["session_enabled"] = session_enabled + if session_blocked is not None: + session_config["session_blocked"] = session_blocked await self.preferences.session_put( umo, "session_service_config", session_config @@ -620,6 +663,8 @@ async def batch_update_service(self, data: object) -> dict: status_changes.append(f"TTS={'启用' if tts_enabled else '禁用'}") if session_enabled is not None: status_changes.append(f"会话={'启用' if session_enabled else '禁用'}") + if session_blocked is not None: + status_changes.append(f"完全禁用={'是' if session_blocked else '否'}") return { "message": f"已更新 {success_count} 个会话 ({', '.join(status_changes)})", diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index 30a3b4a98c..13ed982a78 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -842,6 +842,7 @@ export type BatchSessionServiceRequest = UmoListRequest & { session_enabled?: boolean; llm_enabled?: boolean; tts_enabled?: boolean; + session_blocked?: boolean; }; export type SessionGroupRequest = { diff --git a/dashboard/src/i18n/locales/en-US/features/session-management.json b/dashboard/src/i18n/locales/en-US/features/session-management.json index 4b21999a31..943192ae17 100644 --- a/dashboard/src/i18n/locales/en-US/features/session-management.json +++ b/dashboard/src/i18n/locales/en-US/features/session-management.json @@ -61,6 +61,7 @@ "sessionEnabled": "Enable Session", "llmEnabled": "Enable LLM", "ttsEnabled": "Enable TTS", + "sessionBlocked": "Fully block all functionality", "customName": "Custom Name" }, "providerConfig": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/session-management.json b/dashboard/src/i18n/locales/zh-CN/features/session-management.json index e683b78a6b..8efd9a0806 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/session-management.json +++ b/dashboard/src/i18n/locales/zh-CN/features/session-management.json @@ -61,6 +61,7 @@ "sessionEnabled": "启用该消息会话来源的消息处理", "llmEnabled": "启用 LLM", "ttsEnabled": "启用 TTS", + "sessionBlocked": "完全禁用该会话的所有功能", "customName": "消息会话来源备注名称" }, "providerConfig": { diff --git a/dashboard/src/views/SessionManagementPage.vue b/dashboard/src/views/SessionManagementPage.vue index 56ccd4c3c4..0bbbbf6934 100644 --- a/dashboard/src/views/SessionManagementPage.vue +++ b/dashboard/src/views/SessionManagementPage.vue @@ -703,6 +703,14 @@ hide-details /> + + + > & { custom_name: string; @@ -1240,6 +1249,7 @@ const serviceConfig = reactive< session_enabled: true, llm_enabled: true, tts_enabled: true, + session_blocked: false, custom_name: '', persona_id: null, }); @@ -1584,6 +1594,7 @@ function normalizeSessionServiceConfig(raw: unknown): SessionServiceConfig { session_enabled: normalizeBoolean(source.session_enabled, true), llm_enabled: normalizeBoolean(source.llm_enabled, true), tts_enabled: normalizeBoolean(source.tts_enabled, true), + session_blocked: normalizeBoolean(source.session_blocked, false), custom_name: normalizeString(source.custom_name) || undefined, persona_id: normalizeString(source.persona_id) || null, }; @@ -1855,6 +1866,17 @@ function buildBatchTargetPayload( return payload; } +function sessionAliasName( + item: UmoDisplayInfo | SessionRuleItem | null | undefined, +): string { + if (!item) { + return ''; + } + return ( + item.user_alias || item.rules?.session_service_config?.custom_name || '' + ); +} + function getUmoDisplayText(value: unknown): string { const item = typeof value === 'string' @@ -1865,8 +1887,7 @@ function getUmoDisplayText(value: unknown): string { } const umo = item.umo || normalizeUmoValue(value); - const aliasName = - item.user_alias || item.rules?.session_service_config?.custom_name || ''; + const aliasName = sessionAliasName(item); const autoName = item.auto_name || ''; let displayName = ''; @@ -1893,8 +1914,7 @@ function getUmoSelectionText(value: unknown): string { } const umo = item.umo || normalizeUmoValue(value); - const aliasName = - item.user_alias || item.rules?.session_service_config?.custom_name || ''; + const aliasName = sessionAliasName(item); const autoName = item.auto_name || ''; if (aliasName && autoName && aliasName !== autoName) { @@ -2058,7 +2078,8 @@ function openRuleEditor(item: SessionRuleItem) { serviceConfig.session_enabled = svcConfig.session_enabled !== false; serviceConfig.llm_enabled = svcConfig.llm_enabled !== false; serviceConfig.tts_enabled = svcConfig.tts_enabled !== false; - serviceConfig.custom_name = svcConfig.custom_name || ''; + serviceConfig.session_blocked = svcConfig.session_blocked === true; + serviceConfig.custom_name = sessionAliasName(item); serviceConfig.persona_id = svcConfig.persona_id || null; providerConfig.chat_completion = @@ -2097,10 +2118,9 @@ async function saveServiceConfig() { session_enabled: serviceConfig.session_enabled, llm_enabled: serviceConfig.llm_enabled, tts_enabled: serviceConfig.tts_enabled, + session_blocked: serviceConfig.session_blocked, + custom_name: serviceConfig.custom_name, }; - if (serviceConfig.custom_name) { - config.custom_name = serviceConfig.custom_name; - } if (serviceConfig.persona_id !== null) { config.persona_id = serviceConfig.persona_id; } @@ -2116,9 +2136,12 @@ async function saveServiceConfig() { return; } - editingRules.value.session_service_config = config; + const stored = { ...config }; + delete stored.custom_name; + editingRules.value.session_service_config = stored; const item = ensureRuleItem(selectedUmo.value.umo); - item.rules = { ...item.rules, session_service_config: config }; + item.user_alias = serviceConfig.custom_name; + item.rules = { ...item.rules, session_service_config: stored }; showSuccess(tm('messages.saveSuccess')); } catch { showError(tm('messages.saveError')); @@ -2386,8 +2409,7 @@ function showError(message: string) { function openQuickEditName(item: SessionRuleItem) { quickEditNameTarget.value = item; - quickEditNameValue.value = - item.rules?.session_service_config?.custom_name || ''; + quickEditNameValue.value = sessionAliasName(item); quickEditNameDialog.value = true; } @@ -2405,14 +2427,10 @@ async function saveQuickEditName() { session_enabled: existingConfig.session_enabled !== false, llm_enabled: existingConfig.llm_enabled !== false, tts_enabled: existingConfig.tts_enabled !== false, + session_blocked: existingConfig.session_blocked === true, + custom_name: quickEditNameValue.value, }; - if (quickEditNameValue.value) { - config.custom_name = quickEditNameValue.value; - } else { - delete config.custom_name; - } - const response = await sessionApi.upsertRule({ umo: quickEditNameTarget.value.umo, rule_key: 'session_service_config', @@ -2424,8 +2442,11 @@ async function saveQuickEditName() { return; } + const stored = { ...config }; + delete stored.custom_name; const item = ensureRuleItem(quickEditNameTarget.value.umo); - item.rules.session_service_config = config; + item.user_alias = quickEditNameValue.value; + item.rules.session_service_config = stored; quickEditNameDialog.value = false; quickEditNameTarget.value = null; quickEditNameValue.value = ''; diff --git a/docs/en/dev/architecture.md b/docs/en/dev/architecture.md index b7e18154de..f01d99b11d 100644 --- a/docs/en/dev/architecture.md +++ b/docs/en/dev/architecture.md @@ -289,6 +289,8 @@ Actions use `domain.verb`. Built-in commands declare them with `@filter.permissi | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | `session.read` | member+ on the current session | no | | `session.manage` | session_admin+ | no | +| `session.read_target` / `session.manage_target` | instance_operator+ | no | +| `session.block` | instance_operator+ | no | | `session.assign` | session_owner+ | yes across sessions | | `session.watch` | instance_operator+; both sessions must share a config | no | | `session.send` | instance_operator+; both sessions must share a config | no | diff --git a/docs/en/dev/star/guides/send-message.md b/docs/en/dev/star/guides/send-message.md index 3f3feb3144..ee370296a2 100644 --- a/docs/en/dev/star/guides/send-message.md +++ b/docs/en/dev/star/guides/send-message.md @@ -77,7 +77,7 @@ async def watch_room(self, event: AstrMessageEvent, target_umo: str): - `watch(event, target_umo, *, source_umo=None, ttl_seconds=None)`: create an expiring watch owned by the event's trusted actor; returns `SessionWatch`. `source_umo` is the listening session that receives forwards and defaults to the current session. `ttl_seconds` is 60–864000, default 43200. When it expires, the listener session receives an end notice. - `unwatch(event, target_umo, *, source_umo=None)`: stop a matching watch this actor created. -- `list(event)`: list this actor's active watches whose listener is the current session. +- `list(event, *, source_umo=None)`: list this actor's active watches. `source_umo` is the listening session and defaults to the current session. - `send(event, target_umo)`: deliver the current message body and attachments after stripping the command header; returns `DeliveryReceipt`. These methods call `authorize()` again. They require `session.watch` or diff --git a/docs/en/use/command.md b/docs/en/use/command.md index ba04016c39..16cd66f626 100644 --- a/docs/en/use/command.md +++ b/docs/en/use/command.md @@ -57,9 +57,10 @@ Both `enable` and `disable` are idempotent. They write the existing `session_ena ### Session Information -- `/session info`: Show the UMO, user ID, authorization subject (`im:{platform}:{bot}:{sender}`), platform ID, message type, and session ID. -- `/session name`: Show the current auto name and saved alias; requires `session.manage`. -- `/session name `: Set the current UMO display alias; requires `session.manage`. `GreedyStr` allows spaces. +- `/session info [this|UMO]`: Show the UMO, user ID, authorization subject (`im:{platform}:{bot}:{sender}`), platform ID, message type, and session ID. Omit the argument or use `this` for the current session; an explicit UMO requires `instance_operator` or above and shows that session's auto name and alias. +- `/session name [--target UMO]`: Show the auto name and saved alias; requires `session.manage`. `--target` requires `instance_operator` or above. +- `/session name [--target UMO] `: Set the display alias; requires `session.manage`. `GreedyStr` allows spaces. +- `/session name [--target UMO] --clear`: Clear the display alias. After the waking stage finalizes `is_wake`, the automatic name is written to storage. A manual alias takes priority; automatic upserts do not overwrite `user_alias`. @@ -68,7 +69,7 @@ The user ID from `/session info` can be granted current-session `session_admin` ### Cross-session watches and sending - `/session watch [listener|this] [seconds]`: Forward subsequent incoming messages from the target into the listener session; requires `session.watch`. Omit the listener or write `this` for the current session. Duration is 60–864000 seconds (up to 10 days), default 43200 seconds (12 hours). When it expires, the listener session receives an end notice. -- `/session watches`: List watches you created with the current session as the listener, and their remaining time. +- `/session watches [listener|this]`: List watches you created and their remaining time. Omit the argument or write `this` for the current session as the listener. - `/session unwatch [listener|this] `: Stop a matching watch that you own. Omit the listener or write `this` for the current session. - `/send [content]`: Send text and attachments from the same message through the target Bot account; requires `session.send`. An attachment-only body is allowed. This does not register a `reply` command. @@ -126,6 +127,14 @@ These commands require `provider.use`. Cross-session assignment also requires `s These commands require `session.manage`. Both `enable` and `disable` are idempotent. `/llm` only controls whether the LLM is enabled; it does not change streaming mode. +### TTS state + +- `/tts status`: Show whether TTS is enabled for the current session. +- `/tts enable`: Enable TTS for the current session. +- `/tts disable`: Disable TTS for the current session. + +These commands require `session.manage`. Both `enable` and `disable` are idempotent. `/bot status` shows the session, LLM, and TTS switches together. + ### Session streaming - `/flow enable`: Force streaming for the current session. diff --git a/docs/en/use/custom-rules.md b/docs/en/use/custom-rules.md index 8cf6a91537..0c5120c72c 100644 --- a/docs/en/use/custom-rules.md +++ b/docs/en/use/custom-rules.md @@ -25,10 +25,11 @@ Each rule binds to one UMO and may include several overlays: - Whether to process messages for this session. Off is equivalent to blacklisting the UMO. - Whether LLM is enabled. Off skips AI; commands may still run. - Whether TTS is enabled. +- Whether all functionality is fully blocked (`session_blocked`). A fully blocked session only allows `/bot status` and `/session unblock`. - Forced persona. Outranks conversation choice and the profile default. See [Personas](./persona#which-persona-is-selected). -- Display name (`custom_name`). +- Display alias. This is the same `user_alias` written by `/session name`, not a separate `custom_name` field. -`/bot enable`, `/bot disable`, `/llm enable`, and `/llm disable` write this same service rule. `/bot status` shows the session, LLM, and TTS switches. Those commands need `session.manage`. See [Built-in commands](./command). +`/bot enable`, `/bot disable`, `/llm enable`, `/llm disable`, `/tts enable`, `/tts disable`, `/session block`, and `/session unblock` write this same service rule. `/bot status` shows the session, LLM, TTS, and full-block switches. Those commands need `session.manage` or `session.block`. See [Built-in commands](./command). ### Plugin rules (`session_plugin_config`) diff --git a/docs/public/openapi.json b/docs/public/openapi.json index ec6318bfb2..c9013530f0 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -6917,6 +6917,9 @@ }, "tts_enabled": { "type": "boolean" + }, + "session_blocked": { + "type": "boolean" } } } diff --git a/docs/zh/dev/architecture.md b/docs/zh/dev/architecture.md index a929ae8d84..3ca9e95d90 100644 --- a/docs/zh/dev/architecture.md +++ b/docs/zh/dev/architecture.md @@ -289,6 +289,8 @@ guest: | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------ | | `session.read` | member 及以上(当前会话) | 否 | | `session.manage` | session_admin 及以上 | 否 | +| `session.read_target` / `session.manage_target` | instance_operator 及以上 | 否 | +| `session.block` | instance_operator 及以上 | 否 | | `session.assign` | session_owner 及以上 | 跨会话时需更高角色 | | `session.watch` | instance_operator 及以上;两会话须同一配置 | 否 | | `session.send` | instance_operator 及以上;两会话须同一配置 | 否 | diff --git a/docs/zh/dev/star/guides/send-message.md b/docs/zh/dev/star/guides/send-message.md index 7f2e2720e8..36e26f3c5a 100644 --- a/docs/zh/dev/star/guides/send-message.md +++ b/docs/zh/dev/star/guides/send-message.md @@ -69,7 +69,7 @@ async def watch_room(self, event: AstrMessageEvent, target_umo: str): - `watch(event, target_umo, *, source_umo=None, ttl_seconds=None)`:用事件上的可信主体创建有期限监听,返回 `SessionWatch`。`source_umo` 是接收转发的监听会话,缺省为当前会话;`ttl_seconds` 范围 60–864000,缺省 43200。到期后会向监听会话发送结束通知。 - `unwatch(event, target_umo, *, source_umo=None)`:停止当前主体创建的对应监听。 -- `list(event)`:列出当前主体以本会话为监听会话的有效监听。 +- `list(event, *, source_umo=None)`:列出当前主体的有效监听。`source_umo` 是监听会话,缺省为当前会话。 - `send(event, target_umo)`:把当前消息去掉指令头后的正文和附件投递到目标会话,返回 `DeliveryReceipt`。 这些方法会再次调用 `authorize()`,要求 `session.watch` 或 `session.send`,且两个会话属于同一配置。监听保存在内存中,到期或重启后清空。不要自己构造 `SessionBridgeManager`。`SessionWatch` 和时长常量可从 `astrbot.api.platform` 导入。当前没有 Dashboard 管理面,插件也不应假设存在对应 HTTP API。 diff --git a/docs/zh/use/command.md b/docs/zh/use/command.md index 3e64263073..27d4bca242 100644 --- a/docs/zh/use/command.md +++ b/docs/zh/use/command.md @@ -57,9 +57,10 @@ Orbit 不执行变量、命令、算术或波浪号展开,也不执行 glob、 ### 会话信息 -- `/session info`:显示 UMO、用户 ID、授权主体(`im:{platform}:{bot}:{sender}`)、平台 ID、消息类型和会话 ID。 -- `/session name`:显示当前自动名称和已保存别名,需要 `session.manage`。 -- `/session name <名称>`:设置当前 UMO 的展示别名,需要 `session.manage`。名称由 `GreedyStr` 接收,可以包含空格。 +- `/session info [this|UMO]`:显示 UMO、用户 ID、授权主体(`im:{platform}:{bot}:{sender}`)、平台 ID、消息类型和会话 ID。省略参数或使用 `this` 表示当前会话;指定 UMO 时需要 `instance_operator` 及以上权限,并显示目标会话的自动名称和别名。 +- `/session name [--target UMO]`:显示自动名称和已保存别名,需要 `session.manage`。指定 `--target` 时需要 `instance_operator` 及以上权限。 +- `/session name [--target UMO] <名称>`:设置展示别名,需要 `session.manage`。名称由 `GreedyStr` 接收,可以包含空格。 +- `/session name [--target UMO] --clear`:清除展示别名。 唤醒阶段在 `is_wake` 确定后会把自动名写入存储;手动别名优先,自动 upsert 不覆盖 `user_alias`。 @@ -68,7 +69,7 @@ Orbit 不执行变量、命令、算术或波浪号展开,也不执行 glob、 ### 跨会话监听与发送 - `/session watch [监听会话|this] <被监听会话> [秒数]`:把被监听会话后续收到的消息转发到监听会话,需要 `session.watch`。监听会话可省略或写 `this`,表示当前会话。时长 60–864000 秒(最多 10 天),默认 43200 秒(12 小时)。到期后会在监听会话发送结束通知。 -- `/session watches`:查看你在当前会话作为监听会话创建的监听和剩余时间。 +- `/session watches [监听会话|this]`:查看你创建的监听和剩余时间。省略参数或写 `this` 表示当前会话作为监听端。 - `/session unwatch [监听会话|this] <被监听会话>`:停止你创建的指定监听。监听会话可省略或写 `this`。 - `/send [内容]`:借助目标平台的 Bot 账号发送文字和同一条消息中的附件,需要 `session.send`。可以只附图片而不填写正文;不会占用 `reply` 指令。 @@ -126,6 +127,14 @@ LINE 等需要公网媒体 URL 的目标要求配置可访问的 HTTPS `callback 这些指令需要 `session.manage`。`enable` 和 `disable` 都是幂等操作。`/llm` 只控制是否启用 LLM,与流式模式无关。 +### TTS 状态 + +- `/tts status`:显示当前会话是否启用 TTS。 +- `/tts enable`:启用当前会话的 TTS。 +- `/tts disable`:停用当前会话的 TTS。 + +这些指令需要 `session.manage`。`enable` 和 `disable` 都是幂等操作。`/bot status` 可以同时查看会话、LLM 和 TTS 开关。 + ### 会话流式输出 - `/flow enable`:当前会话强制流式。 diff --git a/docs/zh/use/custom-rules.md b/docs/zh/use/custom-rules.md index 8ed8748ee2..1febf19c73 100644 --- a/docs/zh/use/custom-rules.md +++ b/docs/zh/use/custom-rules.md @@ -25,10 +25,11 @@ UMO 唯一指定某个平台下的一个会话。用 `/session info` 查看。 - 是否处理该会话的消息。关掉约等于把这个 UMO 拉黑。 - 是否对该会话启用 LLM。关掉后不走 AI,指令仍可能执行。 - 是否对该会话启用 TTS。 +- 是否完全禁用该会话的所有功能(`session_blocked`)。完全禁用后只放行 `/bot status` 和 `/session unblock`。 - 强制人格。优先级高于对话选择和配置文件默认人格,见 [Persona](./persona#哪个-persona-会生效)。 -- 展示名(`custom_name`)。 +- 展示别名。与 `/session name` 写入同一份 `user_alias`,不是单独的 `custom_name` 字段。 -聊天里的 `/bot enable`、`/bot disable`、`/llm enable`、`/llm disable` 写的就是这一套服务规则。`/bot status` 可以查看当前会话的整体开关、LLM 和 TTS。这些指令需要 `session.manage`,见 [内置指令](./command)。 +聊天里的 `/bot enable`、`/bot disable`、`/llm enable`、`/llm disable`、`/tts enable`、`/tts disable`、`/session block`、`/session unblock` 写的就是这一套服务规则。`/bot status` 可以查看当前会话的整体开关、LLM、TTS 和完全禁用状态。这些指令需要 `session.manage` 或 `session.block`,见 [内置指令](./command)。 ### 插件规则(`session_plugin_config`) diff --git a/openspec/openapi-v1.yaml b/openspec/openapi-v1.yaml index 04db11e561..9ef090151f 100644 --- a/openspec/openapi-v1.yaml +++ b/openspec/openapi-v1.yaml @@ -7023,6 +7023,8 @@ components: type: boolean tts_enabled: type: boolean + session_blocked: + type: boolean SessionGroupRequest: type: object diff --git a/tests/unit/test_builtin_command_extensions.py b/tests/unit/test_builtin_command_extensions.py index c5a4fdd6b5..502bb530ad 100644 --- a/tests/unit/test_builtin_command_extensions.py +++ b/tests/unit/test_builtin_command_extensions.py @@ -15,6 +15,7 @@ from astrbot.builtin_stars.builtin_commands.commands.persona import PersonaCommands from astrbot.builtin_stars.builtin_commands.commands.plugin import PluginCommands from astrbot.builtin_stars.builtin_commands.commands.provider import ProviderCommands +from astrbot.builtin_stars.builtin_commands.commands.tts import TtsCommands from astrbot.builtin_stars.builtin_commands.commands.work import WorkCommands from astrbot.builtin_stars.builtin_commands.main import Main from astrbot.core.command import ( @@ -257,6 +258,15 @@ def test_all_builtin_extension_commands_use_native_command_schemas(): "model_set", "session_name", "session_info", + "session_watch", + "session_unwatch", + "session_watches", + "session_block", + "session_unblock", + "send_to_session", + "tts_disable", + "tts_enable", + "tts_status", "persona_list", "persona_set", "persona_status", @@ -805,6 +815,51 @@ async def session_put(umo: str, key: str, value: dict[str, bool]) -> None: assert "enabled" in _plain_text(enable_event.result) +@pytest.mark.asyncio +async def test_tts_commands_report_and_set_session_service_status(): + calls: list[tuple[str, dict[str, bool]]] = [] + settings = {"tts_enabled": True} + + async def session_get( + umo: str, key: str, default: dict[str, bool] + ) -> dict[str, bool]: + assert umo == "napcat:FriendMessage:42" + assert key == "session_service_config" + assert default == {} + return dict(settings) + + async def session_put(umo: str, key: str, value: dict[str, bool]) -> None: + assert umo == "napcat:FriendMessage:42" + assert key == "session_service_config" + calls.append((umo, dict(value))) + settings.update(value) + + command = TtsCommands( + SimpleNamespace( + preferences=SimpleNamespace( + session_get=session_get, + session_put=session_put, + ), + i18n=FakeI18n(), + ) + ) + status_event = DummyEvent(message_str="tts status") + await command.status(status_event) + assert "enabled" in _plain_text(status_event.result) + + disable_event = DummyEvent(message_str="tts disable") + await command.set_enabled(disable_event, False) + enable_event = DummyEvent(message_str="tts enable") + await command.set_enabled(enable_event, True) + + assert calls == [ + ("napcat:FriendMessage:42", {"tts_enabled": False}), + ("napcat:FriendMessage:42", {"tts_enabled": True}), + ] + assert "disabled" in _plain_text(disable_event.result) + assert "enabled" in _plain_text(enable_event.result) + + def test_bot_flag_enabled_defaults_missing_and_invalid_values(): assert _flag_enabled({}, "session_enabled") is True assert _flag_enabled({"session_enabled": False}, "session_enabled") is False @@ -1058,6 +1113,7 @@ def command_names(group: CommandGroupFilter) -> set[str]: "model": {"list", "set"}, "variable": {"set", "unset"}, "llm": {"disable", "enable", "status"}, + "tts": {"disable", "enable", "status"}, "flow": {"disable", "enable", "status", "unset"}, "admin": {"grant", "list", "revoke"}, "persona": {"list", "set", "show", "status", "unset"}, @@ -1071,12 +1127,16 @@ def command_names(group: CommandGroupFilter) -> set[str]: history_param = compile_command_schema(Main.conversation_history).params[0] list_param = compile_command_schema(Main.conversation_list).params[0] leave_param = compile_command_schema(Main.bot_leave).params[0] + name_params = compile_command_schema(Main.session_name).params assert history_param.option.names == ("--page", "-p") assert list_param.option.names == ("--page", "-p") assert history_param.default == 1 assert list_param.default == 1 assert leave_param.option.names == ("--confirm", "-c") assert leave_param.default is False + assert [param.name for param in name_params] == ["alias", "target", "clear"] + assert name_params[1].option.names == ("--target", "-t") + assert name_params[2].option.names == ("--clear", "-c") def test_non_public_builtin_commands_declare_the_planned_actions(): @@ -1098,6 +1158,15 @@ def test_non_public_builtin_commands_declare_the_planned_actions(): assert { "session_info": "session.read", + "session_watch": "session.watch", + "session_unwatch": "session.read", + "session_watches": "session.read", + "session_block": "session.block", + "session_unblock": "session.block", + "send_to_session": "session.send", + "tts_status": "session.manage", + "tts_enable": "session.manage", + "tts_disable": "session.manage", "bot_status": "session.read", "bot_enable": "session.manage", "bot_disable": "session.manage", @@ -1140,6 +1209,22 @@ def test_normalized_builtin_paths_resolve_and_legacy_subcommands_do_not(): flow = engine.resolve("flow enable") assert flow.resolution.command_path == ("flow", "enable") + tts = engine.resolve("tts enable") + assert tts.resolution.command_path == ("tts", "enable") + + named = engine.resolve("session name --clear") + assert named.resolution.command_path == ("session", "name") + named_entry = named.resolution.entries[0] + assert dict(engine.bind(named_entry, named).values)["clear"] is True + + targeted = engine.resolve("session name --target qq:GroupMessage:2000 Ops Room") + targeted_entry = targeted.resolution.entries[0] + assert dict(engine.bind(targeted_entry, targeted).values) == { + "alias": "Ops Room", + "target": "qq:GroupMessage:2000", + "clear": False, + } + bot_leave = engine.resolve("bot leave --confirm") assert bot_leave.resolution.command_path == ("bot", "leave") bot_entry = bot_leave.resolution.entries[0] diff --git a/tests/unit/test_message_protocol.py b/tests/unit/test_message_protocol.py index 44336ca304..578a0f4046 100644 --- a/tests/unit/test_message_protocol.py +++ b/tests/unit/test_message_protocol.py @@ -419,6 +419,15 @@ async def test_watch_ownership_includes_both_source_and_actor(): await manager.watch(second, target) assert not await manager.unwatch(_event(sender="other"), target) assert len(await manager.list_watches(first)) == 1 + assert ( + len( + await manager.list_watches( + first, + source_umo=second.unified_msg_origin, + ) + ) + == 1 + ) assert await manager.unwatch(first, target) assert len(await manager.list_watches(second)) == 1 await manager.terminate() @@ -881,6 +890,7 @@ def test_parse_watch_spec_accepts_this_omission_and_duration(): from astrbot.builtin_stars.builtin_commands.commands.session import ( parse_unwatch_spec, parse_watch_spec, + parse_watches_spec, ) current = "here:FriendMessage:me" @@ -910,6 +920,11 @@ def test_parse_watch_spec_accepts_this_omission_and_duration(): current, "there:GroupMessage:room", ) + assert parse_watches_spec("", current) == current + assert parse_watches_spec("this", current) == current + assert parse_watches_spec("other:FriendMessage:x", current) == ( + "other:FriendMessage:x" + ) @pytest.mark.asyncio diff --git a/tests/unit/test_session_management_service.py b/tests/unit/test_session_management_service.py index 25a1ac761c..afaecbbb92 100644 --- a/tests/unit/test_session_management_service.py +++ b/tests/unit/test_session_management_service.py @@ -361,6 +361,60 @@ async def test_session_rule_validation_and_provider_override_lifecycle(session_s assert preferences.session_values == {} +@pytest.mark.asyncio +async def test_service_config_custom_name_writes_umo_alias( + session_service, + temp_db, +): + service, preferences, _providers = session_service + umo = "qq:FriendMessage:alice" + + await service.update_session_rule( + { + "umo": umo, + "rule_key": "session_service_config", + "rule_value": { + "session_enabled": True, + "custom_name": "Alice Desk", + }, + } + ) + alias = await temp_db.get_umo_alias(umo) + assert alias is not None + assert alias.user_alias == "Alice Desk" + assert preferences.session_values[(umo, "session_service_config")] == { + "session_enabled": True, + } + + await service.update_session_rule( + { + "umo": umo, + "rule_key": "session_service_config", + "rule_value": { + "session_enabled": True, + "custom_name": "", + }, + } + ) + cleared = await temp_db.get_umo_alias(umo) + assert cleared is not None + assert cleared.user_alias is None + + await service.update_session_rule( + { + "umo": umo, + "rule_key": "session_service_config", + "rule_value": {"session_enabled": False}, + } + ) + assert preferences.session_values[(umo, "session_service_config")] == { + "session_enabled": False, + } + unchanged = await temp_db.get_umo_alias(umo) + assert unchanged is not None + assert unchanged.user_alias is None + + @pytest.mark.asyncio async def test_batch_updates_validate_input_and_report_partial_failures( session_service, @@ -394,6 +448,7 @@ async def test_batch_updates_validate_input_and_report_partial_failures( "umos": [good_umo, bad_umo], "session_enabled": False, "llm_enabled": True, + "session_blocked": True, } ) provider_result = await service.batch_update_provider( @@ -410,6 +465,7 @@ async def test_batch_updates_validate_input_and_report_partial_failures( assert preferences.session_values[(good_umo, "session_service_config")] == { "session_enabled": False, "llm_enabled": True, + "session_blocked": True, } assert provider_result["success_count"] == 1 assert provider_result["failed_count"] == 1 diff --git a/tests/unit/test_umo_alias.py b/tests/unit/test_umo_alias.py index 152e7a65f8..6fb7d31860 100644 --- a/tests/unit/test_umo_alias.py +++ b/tests/unit/test_umo_alias.py @@ -1,6 +1,6 @@ import asyncio from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock import pytest @@ -46,6 +46,7 @@ async def set_alias(**kwargs): set_alias=set_alias, auto_name=get_event_auto_name, normalize_name=normalize_umo_name, + parse=parse_umo, ), i18n=FakeI18n(), config=SimpleNamespace( @@ -192,7 +193,8 @@ async def test_session_name_without_alias_shows_current_names(temp_db): assert result.use_t2i_ is False assert result.chain[0].text == "\n".join( [ - "Usage: /session name ", + "Usage: /session name [--target UMO] ", + "Clear: /session name [--target UMO] --clear", "UMO: qq:GroupMessage:1000", "Auto name: Engineering Group", "Alias: Backend Room", @@ -261,6 +263,92 @@ async def test_session_info_uses_attached_subject_id(): assert f"Subject ID: {attached.id}" in result.chain[0].text +@pytest.mark.asyncio +async def test_session_name_clear_removes_user_alias(temp_db): + await temp_db.upsert_umo_alias( + umo="qq:GroupMessage:1000", + creator_sender_id="sender-1", + auto_name="Engineering Group", + user_alias="Backend Room", + ) + context = make_session_context(temp_db) + event = make_group_event() + + await SessionCommands(context).name(event, "", clear=True) + + alias = await temp_db.get_umo_alias("qq:GroupMessage:1000") + assert alias is not None + assert alias.user_alias is None + result = event.set_result.call_args.args[0] + assert result.chain[0].text == ("UMO alias cleared.\nUMO: qq:GroupMessage:1000") + + +@pytest.mark.asyncio +async def test_session_info_and_name_accept_target_umo(temp_db): + await temp_db.upsert_umo_alias( + umo="qq:GroupMessage:2000", + creator_sender_id="sender-9", + auto_name="Ops Group", + user_alias="Ops Room", + ) + authz = SimpleNamespace( + authorize_target_session=AsyncMock(return_value=SimpleNamespace(allowed=True)) + ) + context = make_session_context(temp_db) + context.authz = authz + event = make_group_event() + commands = SessionCommands(context) + + await commands.info(event, "qq:GroupMessage:2000") + info_text = event.set_result.call_args.args[0].chain[0].text + assert "UMO: qq:GroupMessage:2000" in info_text + assert "Auto name: Ops Group" in info_text + assert "Alias: Ops Room" in info_text + assert "Session ID: 2000" in info_text + authz.authorize_target_session.assert_awaited() + + await commands.name(event, "New Ops", "qq:GroupMessage:2000") + alias = await temp_db.get_umo_alias("qq:GroupMessage:2000") + assert alias is not None + assert alias.user_alias == "New Ops" + + +@pytest.mark.asyncio +async def test_session_info_rejects_unauthorized_and_invalid_targets(): + authz = SimpleNamespace( + authorize_target_session=AsyncMock(return_value=SimpleNamespace(allowed=False)) + ) + context = make_session_context(SimpleNamespace()) + context.authz = authz + event = make_group_event() + commands = SessionCommands(context) + + await commands.info(event, "qq:GroupMessage:2000") + denied = event.set_result.call_args.args[0].chain[0].text + assert "You cannot manage the target session" in denied + authz.authorize_target_session.assert_awaited_once() + + authz.authorize_target_session.side_effect = PermissionError("denied") + await commands.info(event, "qq:GroupMessage:2000") + still_denied = event.set_result.call_args.args[0].chain[0].text + assert "You cannot manage the target session" in still_denied + + await commands.info(event, "this extra") + usage = event.set_result.call_args.args[0].chain[0].text + assert "the target must be this or one UMO" in usage + + +def test_session_capability_parses_umo(): + from astrbot.core.star.plugin_context import SessionCapability + + capability = SessionCapability(MagicMock()) + assert capability.parse("qq:GroupMessage:1000") == { + "platform": "qq", + "message_type": "GroupMessage", + "session_id": "1000", + } + + def test_session_name_requires_session_manage_action(): from astrbot.builtin_stars.builtin_commands.main import Main