Add optional Nanobot plugin controls (#4396)
* feat: add optional nanobot features * test: update azure install hint expectation * fix: validate optional feature extras maintainer edit: verify requested dependency extras before treating optional features as installed, propagate restart state from feature enablement, and align docs with the new plugins enable command. * fix: bound optional feature installs maintainer edit: make optional feature installs time out as a normal install failure instead of leaving the WebUI or CLI action waiting indefinitely. * feat: slim optional channel dependencies * fix: log optional install commands * fix(webui): gate remote feature installs * docs: clarify webhook plugin example * fix(webui): harden optional feature installs * fix: install optional deps without package fallback * fix(cli): refine plugin feature controls * fix(webui): count enabled nanobot features * fix(webui): allow slow feature install routes * fix(webui): allow disabling websocket channel * fix(plugins): simplify optional feature controls * fix(webui): polish apps catalog states * fix(webui): confirm nanobot support installs * fix(webui): polish nanobot install dialog * fix(webui): suppress empty websocket handshakes * fix(webui): clarify apps plugin summary * fix(webui): localize workspace access copy * fix(plugins): polish optional feature controls (#4691) --------- Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
This commit is contained in:
@@ -217,7 +217,7 @@ class DingTalkChannel(BaseChannel):
|
||||
try:
|
||||
if not DINGTALK_AVAILABLE:
|
||||
self.logger.error(
|
||||
"Stream SDK not installed. Run: pip install dingtalk-stream"
|
||||
"Stream SDK not installed. Run: nanobot plugins enable dingtalk"
|
||||
)
|
||||
return
|
||||
|
||||
|
||||
@@ -405,7 +405,7 @@ class DiscordChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Discord client."""
|
||||
if not DISCORD_AVAILABLE:
|
||||
self.logger.error("discord.py not installed. Run: pip install nanobot-ai[discord]")
|
||||
self.logger.error("discord.py not installed. Run: nanobot plugins enable discord")
|
||||
return
|
||||
|
||||
if not self.config.token:
|
||||
|
||||
@@ -672,7 +672,7 @@ class FeishuChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Feishu bot with WebSocket long connection."""
|
||||
if not FEISHU_AVAILABLE:
|
||||
self.logger.error("SDK not installed. Run: pip install lark-oapi")
|
||||
self.logger.error("SDK not installed. Run: nanobot plugins enable feishu")
|
||||
return
|
||||
|
||||
if not self.config.app_id or not self.config.app_secret:
|
||||
|
||||
@@ -25,6 +25,7 @@ from nanobot.bus.outbound_events import (
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.channels.base import BaseChannel
|
||||
from nanobot.channels.registry import DEFAULT_ENABLED_CHANNELS
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.utils.restart import consume_restart_notice_from_env, format_restart_completed_message
|
||||
|
||||
@@ -51,6 +52,21 @@ _BOOL_CAMEL_ALIASES: dict[str, str] = {
|
||||
"show_reasoning": "showReasoning",
|
||||
}
|
||||
|
||||
def _default_channel_config(name: str) -> dict[str, Any] | None:
|
||||
if name != "websocket":
|
||||
return None
|
||||
from nanobot.channels.websocket import WebSocketChannel
|
||||
|
||||
return WebSocketChannel.default_config()
|
||||
|
||||
|
||||
def _channel_config_enabled(name: str, section: Any) -> bool:
|
||||
default_enabled = name in DEFAULT_ENABLED_CHANNELS
|
||||
if isinstance(section, dict):
|
||||
return bool(section.get("enabled", default_enabled))
|
||||
return bool(getattr(section, "enabled", default_enabled))
|
||||
|
||||
|
||||
class ChannelManager:
|
||||
"""
|
||||
Manages chat channels and coordinates message routing.
|
||||
@@ -105,21 +121,32 @@ class ChannelManager:
|
||||
candidate_names = set(names)
|
||||
extra = getattr(self.config.channels, "__pydantic_extra__", None) or {}
|
||||
candidate_names.update(extra.keys())
|
||||
default_sections: dict[str, Any] = {}
|
||||
|
||||
def section_for(name: str) -> Any:
|
||||
section = getattr(self.config.channels, name, None)
|
||||
if section is not None or name not in DEFAULT_ENABLED_CHANNELS:
|
||||
return section
|
||||
if name not in default_sections:
|
||||
default = _default_channel_config(name)
|
||||
if default is not None:
|
||||
default_sections[name] = default
|
||||
return default_sections.get(name)
|
||||
|
||||
enabled_names: set[str] = set()
|
||||
for name in candidate_names:
|
||||
section = getattr(self.config.channels, name, None)
|
||||
section = section_for(name)
|
||||
if section is None:
|
||||
continue
|
||||
if (
|
||||
section.get("enabled", False)
|
||||
if isinstance(section, dict)
|
||||
else getattr(section, "enabled", False)
|
||||
):
|
||||
if _channel_config_enabled(name, section):
|
||||
enabled_names.add(name)
|
||||
|
||||
for name, cls in discover_enabled(enabled_names, _names=names).items():
|
||||
section = getattr(self.config.channels, name, None)
|
||||
for name, cls in discover_enabled(
|
||||
enabled_names,
|
||||
_names=names,
|
||||
warn_import_errors=True,
|
||||
).items():
|
||||
section = section_for(name)
|
||||
if section is None:
|
||||
continue
|
||||
try:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import mimetypes
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
@@ -45,7 +46,7 @@ try:
|
||||
from nio.exceptions import EncryptionError
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"Matrix dependencies not installed. Run: pip install nanobot-ai[matrix]"
|
||||
"Matrix dependencies not installed. Run: nanobot plugins enable matrix"
|
||||
) from e
|
||||
|
||||
from nanobot.bus.events import OutboundMessage
|
||||
@@ -200,7 +201,7 @@ class MatrixConfig(Base):
|
||||
password: str = ""
|
||||
access_token: str = ""
|
||||
device_id: str = ""
|
||||
e2ee_enabled: bool = Field(default=True, alias="e2eeEnabled")
|
||||
e2ee_enabled: bool = Field(default=sys.platform != "win32", alias="e2eeEnabled")
|
||||
sas_verification: bool = Field(default=False, alias="sasVerification")
|
||||
sync_stop_grace_seconds: int = 2
|
||||
max_media_bytes: int = 20 * 1024 * 1024
|
||||
|
||||
@@ -142,7 +142,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the Teams webhook listener."""
|
||||
if not MSTEAMS_AVAILABLE:
|
||||
self.logger.error("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||
self.logger.error("PyJWT not installed. Run: nanobot plugins enable msteams")
|
||||
return
|
||||
|
||||
if not self.config.app_id or not self.config.app_password:
|
||||
@@ -458,7 +458,7 @@ class MSTeamsChannel(BaseChannel):
|
||||
async def _validate_inbound_auth(self, auth_header: str, activity: dict[str, Any]) -> None:
|
||||
"""Validate inbound Bot Framework bearer token."""
|
||||
if not MSTEAMS_AVAILABLE:
|
||||
raise RuntimeError("PyJWT not installed. Run: pip install nanobot-ai[msteams]")
|
||||
raise RuntimeError("PyJWT not installed. Run: nanobot plugins enable msteams")
|
||||
|
||||
if not auth_header.lower().startswith("bearer "):
|
||||
raise ValueError("missing bearer token")
|
||||
|
||||
@@ -195,7 +195,7 @@ class QQChannel(BaseChannel):
|
||||
"""Start the QQ bot with auto-reconnect loop."""
|
||||
redirect_lib_logging("botpy", level="WARNING")
|
||||
if not QQ_AVAILABLE:
|
||||
self.logger.error("SDK not installed. Run: pip install qq-botpy")
|
||||
self.logger.error("SDK not installed. Run: nanobot plugins enable qq")
|
||||
return
|
||||
|
||||
if not self.config.app_id or not self.config.secret:
|
||||
|
||||
@@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
||||
from nanobot.channels.base import BaseChannel
|
||||
|
||||
_INTERNAL = frozenset({"base", "manager", "registry"})
|
||||
DEFAULT_ENABLED_CHANNELS = frozenset({"websocket"})
|
||||
|
||||
|
||||
def discover_channel_names() -> list[str]:
|
||||
@@ -57,6 +58,7 @@ def discover_enabled(
|
||||
*,
|
||||
_names: list[str] | None = None,
|
||||
_include_all_external: bool = False,
|
||||
warn_import_errors: bool = False,
|
||||
) -> dict[str, type[BaseChannel]]:
|
||||
"""Return channels whose module names are in *enabled_names*.
|
||||
|
||||
@@ -72,10 +74,14 @@ def discover_enabled(
|
||||
try:
|
||||
result[modname] = load_channel_class(modname)
|
||||
except ImportError as e:
|
||||
logger.debug("Skipping built-in channel '{}': {}", modname, e)
|
||||
message = "Enabled built-in channel '{}' is not available: {}"
|
||||
if warn_import_errors:
|
||||
logger.warning(message, modname, e)
|
||||
else:
|
||||
logger.debug(message, modname, e)
|
||||
|
||||
external = discover_plugins(None if _include_all_external else enabled_names)
|
||||
shadowed = set(external) & set(result)
|
||||
shadowed = set(external) & set(names)
|
||||
if shadowed:
|
||||
logger.warning("Plugin(s) shadowed by built-in channels (ignored): {}", shadowed)
|
||||
if _include_all_external:
|
||||
|
||||
@@ -59,6 +59,9 @@ from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
|
||||
# Plain HTTP WebUI routes also run through websockets.process_request.
|
||||
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
||||
|
||||
|
||||
class WebSocketConfig(Base):
|
||||
"""WebSocket server channel configuration.
|
||||
@@ -80,7 +83,7 @@ class WebSocketConfig(Base):
|
||||
shared filesystem or an HTTP file server to access these files.
|
||||
"""
|
||||
|
||||
enabled: bool = False
|
||||
enabled: bool = True
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8765
|
||||
unix_socket_path: str = ""
|
||||
@@ -482,6 +485,7 @@ class WebSocketChannel(BaseChannel):
|
||||
handler,
|
||||
socket_path,
|
||||
process_request=process_request,
|
||||
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
|
||||
max_size=self.config.max_message_bytes,
|
||||
ping_interval=self.config.ping_interval_s,
|
||||
ping_timeout=self.config.ping_timeout_s,
|
||||
@@ -495,6 +499,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self.config.host,
|
||||
self.config.port,
|
||||
process_request=process_request,
|
||||
open_timeout=_WEBUI_HTTP_OPEN_TIMEOUT_S,
|
||||
max_size=self.config.max_message_bytes,
|
||||
ping_interval=self.config.ping_interval_s,
|
||||
ping_timeout=self.config.ping_timeout_s,
|
||||
|
||||
@@ -103,7 +103,7 @@ class WecomChannel(BaseChannel):
|
||||
async def start(self) -> None:
|
||||
"""Start the WeCom bot with WebSocket long connection."""
|
||||
if not WECOM_AVAILABLE:
|
||||
self.logger.error("SDK not installed. Run: pip install nanobot-ai[wecom]")
|
||||
self.logger.error("SDK not installed. Run: nanobot plugins enable wecom")
|
||||
return
|
||||
|
||||
if not self.config.bot_id or not self.config.secret:
|
||||
|
||||
@@ -72,7 +72,7 @@ def _load_neonize() -> _NeonizeAPI:
|
||||
from neonize.utils.jid import build_jid
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
'WhatsApp dependencies not installed. Run: pip install "nanobot-ai[whatsapp]"'
|
||||
"WhatsApp dependencies not installed. Run: nanobot plugins enable whatsapp"
|
||||
) from exc
|
||||
|
||||
_NEONIZE_API = _NeonizeAPI(
|
||||
|
||||
Reference in New Issue
Block a user