diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index dc53ba4..74c540a 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -1,6 +1,9 @@ """Base channel interface for chat platforms.""" +from __future__ import annotations + from abc import ABC, abstractmethod +from pathlib import Path from typing import Any from loguru import logger @@ -18,6 +21,8 @@ class BaseChannel(ABC): """ name: str = "base" + display_name: str = "Base" + transcription_api_key: str = "" def __init__(self, config: Any, bus: MessageBus): """ @@ -31,6 +36,19 @@ class BaseChannel(ABC): self.bus = bus self._running = False + async def transcribe_audio(self, file_path: str | Path) -> str: + """Transcribe an audio file via Groq Whisper. Returns empty string on failure.""" + if not self.transcription_api_key: + return "" + try: + from nanobot.providers.transcription import GroqTranscriptionProvider + + provider = GroqTranscriptionProvider(api_key=self.transcription_api_key) + return await provider.transcribe(file_path) + except Exception as e: + logger.warning("{}: audio transcription failed: {}", self.name, e) + return "" + @abstractmethod async def start(self) -> None: """ diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index cdcba57..4626d95 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -114,6 +114,7 @@ class DingTalkChannel(BaseChannel): """ name = "dingtalk" + display_name = "DingTalk" _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} _AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 2ee4f77..afa20c9 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -25,6 +25,7 @@ class DiscordChannel(BaseChannel): """Discord channel using Gateway websocket.""" name = "discord" + display_name = "Discord" def __init__(self, config: DiscordConfig, bus: MessageBus): super().__init__(config, bus) diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 16771fb..46c2103 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -35,6 +35,7 @@ class EmailChannel(BaseChannel): """ name = "email" + display_name = "Email" _IMAP_MONTHS = ( "Jan", "Feb", diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0409c32..160b9b4 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -244,11 +244,11 @@ class FeishuChannel(BaseChannel): """ name = "feishu" + display_name = "Feishu" - def __init__(self, config: FeishuConfig, bus: MessageBus, groq_api_key: str = ""): + def __init__(self, config: FeishuConfig, bus: MessageBus): super().__init__(config, bus) self.config: FeishuConfig = config - self.groq_api_key = groq_api_key self._client: Any = None self._ws_client: Any = None self._ws_thread: threading.Thread | None = None @@ -928,16 +928,10 @@ class FeishuChannel(BaseChannel): if file_path: media_paths.append(file_path) - # Transcribe audio using Groq Whisper - if msg_type == "audio" and file_path and self.groq_api_key: - try: - from nanobot.providers.transcription import GroqTranscriptionProvider - transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) - transcription = await transcriber.transcribe(file_path) - if transcription: - content_text = f"[transcription: {transcription}]" - except Exception as e: - logger.warning("Failed to transcribe audio: {}", e) + if msg_type == "audio" and file_path: + transcription = await self.transcribe_audio(file_path) + if transcription: + content_text = f"[transcription: {transcription}]" content_parts.append(content_text) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 2c5cd3f..8288ad0 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -31,135 +31,23 @@ class ChannelManager: self._init_channels() def _init_channels(self) -> None: - """Initialize channels based on config.""" + """Initialize channels discovered via pkgutil scan.""" + from nanobot.channels.registry import discover_channel_names, load_channel_class - # Telegram channel - if self.config.channels.telegram.enabled: + groq_key = self.config.providers.groq.api_key + + for modname in discover_channel_names(): + section = getattr(self.config.channels, modname, None) + if not section or not getattr(section, "enabled", False): + continue try: - from nanobot.channels.telegram import TelegramChannel - self.channels["telegram"] = TelegramChannel( - self.config.channels.telegram, - self.bus, - groq_api_key=self.config.providers.groq.api_key, - ) - logger.info("Telegram channel enabled") + cls = load_channel_class(modname) + channel = cls(section, self.bus) + channel.transcription_api_key = groq_key + self.channels[modname] = channel + logger.info("{} channel enabled", cls.display_name) except ImportError as e: - logger.warning("Telegram channel not available: {}", e) - - # WhatsApp channel - if self.config.channels.whatsapp.enabled: - try: - from nanobot.channels.whatsapp import WhatsAppChannel - self.channels["whatsapp"] = WhatsAppChannel( - self.config.channels.whatsapp, self.bus - ) - logger.info("WhatsApp channel enabled") - except ImportError as e: - logger.warning("WhatsApp channel not available: {}", e) - - # Discord channel - if self.config.channels.discord.enabled: - try: - from nanobot.channels.discord import DiscordChannel - self.channels["discord"] = DiscordChannel( - self.config.channels.discord, self.bus - ) - logger.info("Discord channel enabled") - except ImportError as e: - logger.warning("Discord channel not available: {}", e) - - # Feishu channel - if self.config.channels.feishu.enabled: - try: - from nanobot.channels.feishu import FeishuChannel - self.channels["feishu"] = FeishuChannel( - self.config.channels.feishu, self.bus, - groq_api_key=self.config.providers.groq.api_key, - ) - logger.info("Feishu channel enabled") - except ImportError as e: - logger.warning("Feishu channel not available: {}", e) - - # Mochat channel - if self.config.channels.mochat.enabled: - try: - from nanobot.channels.mochat import MochatChannel - - self.channels["mochat"] = MochatChannel( - self.config.channels.mochat, self.bus - ) - logger.info("Mochat channel enabled") - except ImportError as e: - logger.warning("Mochat channel not available: {}", e) - - # DingTalk channel - if self.config.channels.dingtalk.enabled: - try: - from nanobot.channels.dingtalk import DingTalkChannel - self.channels["dingtalk"] = DingTalkChannel( - self.config.channels.dingtalk, self.bus - ) - logger.info("DingTalk channel enabled") - except ImportError as e: - logger.warning("DingTalk channel not available: {}", e) - - # Email channel - if self.config.channels.email.enabled: - try: - from nanobot.channels.email import EmailChannel - self.channels["email"] = EmailChannel( - self.config.channels.email, self.bus - ) - logger.info("Email channel enabled") - except ImportError as e: - logger.warning("Email channel not available: {}", e) - - # Slack channel - if self.config.channels.slack.enabled: - try: - from nanobot.channels.slack import SlackChannel - self.channels["slack"] = SlackChannel( - self.config.channels.slack, self.bus - ) - logger.info("Slack channel enabled") - except ImportError as e: - logger.warning("Slack channel not available: {}", e) - - # QQ channel - if self.config.channels.qq.enabled: - try: - from nanobot.channels.qq import QQChannel - self.channels["qq"] = QQChannel( - self.config.channels.qq, - self.bus, - ) - logger.info("QQ channel enabled") - except ImportError as e: - logger.warning("QQ channel not available: {}", e) - - # Matrix channel - if self.config.channels.matrix.enabled: - try: - from nanobot.channels.matrix import MatrixChannel - self.channels["matrix"] = MatrixChannel( - self.config.channels.matrix, - self.bus, - ) - logger.info("Matrix channel enabled") - except ImportError as e: - logger.warning("Matrix channel not available: {}", e) - - # WeCom channel - if self.config.channels.wecom.enabled: - try: - from nanobot.channels.wecom import WecomChannel - self.channels["wecom"] = WecomChannel( - self.config.channels.wecom, - self.bus, - ) - logger.info("WeCom channel enabled") - except ImportError as e: - logger.warning("WeCom channel not available: {}", e) + logger.warning("{} channel not available: {}", modname, e) self._validate_allow_from() diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 63cb0ca..0d7a908 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -37,6 +37,7 @@ except ImportError as e: ) from e from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.utils.helpers import safe_filename @@ -146,15 +147,15 @@ class MatrixChannel(BaseChannel): """Matrix (Element) channel using long-polling sync.""" name = "matrix" + display_name = "Matrix" - def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False, - workspace: Path | None = None): + def __init__(self, config: Any, bus: MessageBus): super().__init__(config, bus) self.client: AsyncClient | None = None self._sync_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} - self._restrict_to_workspace = restrict_to_workspace - self._workspace = workspace.expanduser().resolve() if workspace else None + self._restrict_to_workspace = False + self._workspace: Path | None = None self._server_upload_limit_bytes: int | None = None self._server_upload_limit_checked = False @@ -677,7 +678,14 @@ class MatrixChannel(BaseChannel): parts: list[str] = [] if isinstance(body := getattr(event, "body", None), str) and body.strip(): parts.append(body.strip()) - if marker: + + if attachment and attachment.get("type") == "audio": + transcription = await self.transcribe_audio(attachment["path"]) + if transcription: + parts.append(f"[transcription: {transcription}]") + else: + parts.append(marker) + elif marker: parts.append(marker) await self._start_typing_keepalive(room.room_id) diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 09e31c3..52e246f 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -216,6 +216,7 @@ class MochatChannel(BaseChannel): """Mochat channel using socket.io with fallback polling workers.""" name = "mochat" + display_name = "Mochat" def __init__(self, config: MochatConfig, bus: MessageBus): super().__init__(config, bus) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 5ac06e3..792cc12 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -54,6 +54,7 @@ class QQChannel(BaseChannel): """QQ channel using botpy SDK with WebSocket connection.""" name = "qq" + display_name = "QQ" def __init__(self, config: QQConfig, bus: MessageBus): super().__init__(config, bus) diff --git a/nanobot/channels/registry.py b/nanobot/channels/registry.py new file mode 100644 index 0000000..eb30ff7 --- /dev/null +++ b/nanobot/channels/registry.py @@ -0,0 +1,35 @@ +"""Auto-discovery for channel modules — no hardcoded registry.""" + +from __future__ import annotations + +import importlib +import pkgutil +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nanobot.channels.base import BaseChannel + +_INTERNAL = frozenset({"base", "manager", "registry"}) + + +def discover_channel_names() -> list[str]: + """Return all channel module names by scanning the package (zero imports).""" + import nanobot.channels as pkg + + return [ + name + for _, name, ispkg in pkgutil.iter_modules(pkg.__path__) + if name not in _INTERNAL and not ispkg + ] + + +def load_channel_class(module_name: str) -> type[BaseChannel]: + """Import *module_name* and return the first BaseChannel subclass found.""" + from nanobot.channels.base import BaseChannel as _Base + + mod = importlib.import_module(f"nanobot.channels.{module_name}") + for attr in dir(mod): + obj = getattr(mod, attr) + if isinstance(obj, type) and issubclass(obj, _Base) and obj is not _Base: + return obj + raise ImportError(f"No BaseChannel subclass in nanobot.channels.{module_name}") diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 0384d8d..5819212 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -21,6 +21,7 @@ class SlackChannel(BaseChannel): """Slack channel using Socket Mode.""" name = "slack" + display_name = "Slack" def __init__(self, config: SlackConfig, bus: MessageBus): super().__init__(config, bus) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 5b294cc..9f93843 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -155,6 +155,7 @@ class TelegramChannel(BaseChannel): """ name = "telegram" + display_name = "Telegram" # Commands registered with Telegram's command menu BOT_COMMANDS = [ @@ -164,15 +165,9 @@ class TelegramChannel(BaseChannel): BotCommand("help", "Show available commands"), ] - def __init__( - self, - config: TelegramConfig, - bus: MessageBus, - groq_api_key: str = "", - ): + def __init__(self, config: TelegramConfig, bus: MessageBus): super().__init__(config, bus) self.config: TelegramConfig = config - self.groq_api_key = groq_api_key self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task @@ -615,11 +610,8 @@ class TelegramChannel(BaseChannel): media_paths.append(str(file_path)) - # Handle voice transcription - if media_type == "voice" or media_type == "audio": - from nanobot.providers.transcription import GroqTranscriptionProvider - transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) - transcription = await transcriber.transcribe(file_path) + if media_type in ("voice", "audio"): + transcription = await self.transcribe_audio(file_path) if transcription: logger.info("Transcribed {}: {}...", media_type, transcription[:50]) content_parts.append(f"[transcription: {transcription}]") diff --git a/nanobot/channels/wecom.py b/nanobot/channels/wecom.py index 72be9e2..e0f4ae0 100644 --- a/nanobot/channels/wecom.py +++ b/nanobot/channels/wecom.py @@ -36,6 +36,7 @@ class WecomChannel(BaseChannel): """ name = "wecom" + display_name = "WeCom" def __init__(self, config: WecomConfig, bus: MessageBus): super().__init__(config, bus) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 1307716..7fffb80 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -22,6 +22,7 @@ class WhatsAppChannel(BaseChannel): """ name = "whatsapp" + display_name = "WhatsApp" def __init__(self, config: WhatsAppConfig, bus: MessageBus): super().__init__(config, bus) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index f5ac859..dd5e60c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -683,6 +683,7 @@ app.add_typer(channels_app, name="channels") @channels_app.command("status") def channels_status(): """Show channel status.""" + from nanobot.channels.registry import discover_channel_names, load_channel_class from nanobot.config.loader import load_config config = load_config() @@ -690,85 +691,19 @@ def channels_status(): table = Table(title="Channel Status") table.add_column("Channel", style="cyan") table.add_column("Enabled", style="green") - table.add_column("Configuration", style="yellow") - # WhatsApp - wa = config.channels.whatsapp - table.add_row( - "WhatsApp", - "✓" if wa.enabled else "✗", - wa.bridge_url - ) - - dc = config.channels.discord - table.add_row( - "Discord", - "✓" if dc.enabled else "✗", - dc.gateway_url - ) - - # Feishu - fs = config.channels.feishu - fs_config = f"app_id: {fs.app_id[:10]}..." if fs.app_id else "[dim]not configured[/dim]" - table.add_row( - "Feishu", - "✓" if fs.enabled else "✗", - fs_config - ) - - # Mochat - mc = config.channels.mochat - mc_base = mc.base_url or "[dim]not configured[/dim]" - table.add_row( - "Mochat", - "✓" if mc.enabled else "✗", - mc_base - ) - - # Telegram - tg = config.channels.telegram - tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]" - table.add_row( - "Telegram", - "✓" if tg.enabled else "✗", - tg_config - ) - - # Slack - slack = config.channels.slack - slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]" - table.add_row( - "Slack", - "✓" if slack.enabled else "✗", - slack_config - ) - - # DingTalk - dt = config.channels.dingtalk - dt_config = f"client_id: {dt.client_id[:10]}..." if dt.client_id else "[dim]not configured[/dim]" - table.add_row( - "DingTalk", - "✓" if dt.enabled else "✗", - dt_config - ) - - # QQ - qq = config.channels.qq - qq_config = f"app_id: {qq.app_id[:10]}..." if qq.app_id else "[dim]not configured[/dim]" - table.add_row( - "QQ", - "✓" if qq.enabled else "✗", - qq_config - ) - - # Email - em = config.channels.email - em_config = em.imap_host if em.imap_host else "[dim]not configured[/dim]" - table.add_row( - "Email", - "✓" if em.enabled else "✗", - em_config - ) + for modname in sorted(discover_channel_names()): + section = getattr(config.channels, modname, None) + enabled = section and getattr(section, "enabled", False) + try: + cls = load_channel_class(modname) + display = cls.display_name + except ImportError: + display = modname.title() + table.add_row( + display, + "[green]\u2713[/green]" if enabled else "[dim]\u2717[/dim]", + ) console.print(table)