refactor: auto-discover channels via pkgutil, eliminate hardcoded registry

This commit is contained in:
Re-bin
2026-03-11 14:23:19 +00:00
parent b957dbc4cf
commit 254cfd48ba
15 changed files with 111 additions and 233 deletions

View File

@@ -1,6 +1,9 @@
"""Base channel interface for chat platforms.""" """Base channel interface for chat platforms."""
from __future__ import annotations
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from loguru import logger
@@ -18,6 +21,8 @@ class BaseChannel(ABC):
""" """
name: str = "base" name: str = "base"
display_name: str = "Base"
transcription_api_key: str = ""
def __init__(self, config: Any, bus: MessageBus): def __init__(self, config: Any, bus: MessageBus):
""" """
@@ -31,6 +36,19 @@ class BaseChannel(ABC):
self.bus = bus self.bus = bus
self._running = False 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 @abstractmethod
async def start(self) -> None: async def start(self) -> None:
""" """

View File

@@ -114,6 +114,7 @@ class DingTalkChannel(BaseChannel):
""" """
name = "dingtalk" name = "dingtalk"
display_name = "DingTalk"
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"} _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"}
_AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"} _AUDIO_EXTS = {".amr", ".mp3", ".wav", ".ogg", ".m4a", ".aac"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".webm"}

View File

@@ -25,6 +25,7 @@ class DiscordChannel(BaseChannel):
"""Discord channel using Gateway websocket.""" """Discord channel using Gateway websocket."""
name = "discord" name = "discord"
display_name = "Discord"
def __init__(self, config: DiscordConfig, bus: MessageBus): def __init__(self, config: DiscordConfig, bus: MessageBus):
super().__init__(config, bus) super().__init__(config, bus)

View File

@@ -35,6 +35,7 @@ class EmailChannel(BaseChannel):
""" """
name = "email" name = "email"
display_name = "Email"
_IMAP_MONTHS = ( _IMAP_MONTHS = (
"Jan", "Jan",
"Feb", "Feb",

View File

@@ -244,11 +244,11 @@ class FeishuChannel(BaseChannel):
""" """
name = "feishu" 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) super().__init__(config, bus)
self.config: FeishuConfig = config self.config: FeishuConfig = config
self.groq_api_key = groq_api_key
self._client: Any = None self._client: Any = None
self._ws_client: Any = None self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None self._ws_thread: threading.Thread | None = None
@@ -928,16 +928,10 @@ class FeishuChannel(BaseChannel):
if file_path: if file_path:
media_paths.append(file_path) media_paths.append(file_path)
# Transcribe audio using Groq Whisper if msg_type == "audio" and file_path:
if msg_type == "audio" and file_path and self.groq_api_key: transcription = await self.transcribe_audio(file_path)
try:
from nanobot.providers.transcription import GroqTranscriptionProvider
transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
transcription = await transcriber.transcribe(file_path)
if transcription: if transcription:
content_text = f"[transcription: {transcription}]" content_text = f"[transcription: {transcription}]"
except Exception as e:
logger.warning("Failed to transcribe audio: {}", e)
content_parts.append(content_text) content_parts.append(content_text)

View File

@@ -31,135 +31,23 @@ class ChannelManager:
self._init_channels() self._init_channels()
def _init_channels(self) -> None: 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 groq_key = self.config.providers.groq.api_key
if self.config.channels.telegram.enabled:
for modname in discover_channel_names():
section = getattr(self.config.channels, modname, None)
if not section or not getattr(section, "enabled", False):
continue
try: try:
from nanobot.channels.telegram import TelegramChannel cls = load_channel_class(modname)
self.channels["telegram"] = TelegramChannel( channel = cls(section, self.bus)
self.config.channels.telegram, channel.transcription_api_key = groq_key
self.bus, self.channels[modname] = channel
groq_api_key=self.config.providers.groq.api_key, logger.info("{} channel enabled", cls.display_name)
)
logger.info("Telegram channel enabled")
except ImportError as e: except ImportError as e:
logger.warning("Telegram channel not available: {}", e) logger.warning("{} channel not available: {}", modname, 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)
self._validate_allow_from() self._validate_allow_from()

View File

@@ -37,6 +37,7 @@ except ImportError as e:
) from e ) from e
from nanobot.bus.events import OutboundMessage from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel from nanobot.channels.base import BaseChannel
from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.config.paths import get_data_dir, get_media_dir
from nanobot.utils.helpers import safe_filename from nanobot.utils.helpers import safe_filename
@@ -146,15 +147,15 @@ class MatrixChannel(BaseChannel):
"""Matrix (Element) channel using long-polling sync.""" """Matrix (Element) channel using long-polling sync."""
name = "matrix" name = "matrix"
display_name = "Matrix"
def __init__(self, config: Any, bus, *, restrict_to_workspace: bool = False, def __init__(self, config: Any, bus: MessageBus):
workspace: Path | None = None):
super().__init__(config, bus) super().__init__(config, bus)
self.client: AsyncClient | None = None self.client: AsyncClient | None = None
self._sync_task: asyncio.Task | None = None self._sync_task: asyncio.Task | None = None
self._typing_tasks: dict[str, asyncio.Task] = {} self._typing_tasks: dict[str, asyncio.Task] = {}
self._restrict_to_workspace = restrict_to_workspace self._restrict_to_workspace = False
self._workspace = workspace.expanduser().resolve() if workspace else None self._workspace: Path | None = None
self._server_upload_limit_bytes: int | None = None self._server_upload_limit_bytes: int | None = None
self._server_upload_limit_checked = False self._server_upload_limit_checked = False
@@ -677,7 +678,14 @@ class MatrixChannel(BaseChannel):
parts: list[str] = [] parts: list[str] = []
if isinstance(body := getattr(event, "body", None), str) and body.strip(): if isinstance(body := getattr(event, "body", None), str) and body.strip():
parts.append(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) parts.append(marker)
await self._start_typing_keepalive(room.room_id) await self._start_typing_keepalive(room.room_id)

View File

@@ -216,6 +216,7 @@ class MochatChannel(BaseChannel):
"""Mochat channel using socket.io with fallback polling workers.""" """Mochat channel using socket.io with fallback polling workers."""
name = "mochat" name = "mochat"
display_name = "Mochat"
def __init__(self, config: MochatConfig, bus: MessageBus): def __init__(self, config: MochatConfig, bus: MessageBus):
super().__init__(config, bus) super().__init__(config, bus)

View File

@@ -54,6 +54,7 @@ class QQChannel(BaseChannel):
"""QQ channel using botpy SDK with WebSocket connection.""" """QQ channel using botpy SDK with WebSocket connection."""
name = "qq" name = "qq"
display_name = "QQ"
def __init__(self, config: QQConfig, bus: MessageBus): def __init__(self, config: QQConfig, bus: MessageBus):
super().__init__(config, bus) super().__init__(config, bus)

View File

@@ -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}")

View File

@@ -21,6 +21,7 @@ class SlackChannel(BaseChannel):
"""Slack channel using Socket Mode.""" """Slack channel using Socket Mode."""
name = "slack" name = "slack"
display_name = "Slack"
def __init__(self, config: SlackConfig, bus: MessageBus): def __init__(self, config: SlackConfig, bus: MessageBus):
super().__init__(config, bus) super().__init__(config, bus)

View File

@@ -155,6 +155,7 @@ class TelegramChannel(BaseChannel):
""" """
name = "telegram" name = "telegram"
display_name = "Telegram"
# Commands registered with Telegram's command menu # Commands registered with Telegram's command menu
BOT_COMMANDS = [ BOT_COMMANDS = [
@@ -164,15 +165,9 @@ class TelegramChannel(BaseChannel):
BotCommand("help", "Show available commands"), BotCommand("help", "Show available commands"),
] ]
def __init__( def __init__(self, config: TelegramConfig, bus: MessageBus):
self,
config: TelegramConfig,
bus: MessageBus,
groq_api_key: str = "",
):
super().__init__(config, bus) super().__init__(config, bus)
self.config: TelegramConfig = config self.config: TelegramConfig = config
self.groq_api_key = groq_api_key
self._app: Application | None = None self._app: Application | None = None
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies 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 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)) media_paths.append(str(file_path))
# Handle voice transcription if media_type in ("voice", "audio"):
if media_type == "voice" or media_type == "audio": transcription = await self.transcribe_audio(file_path)
from nanobot.providers.transcription import GroqTranscriptionProvider
transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key)
transcription = await transcriber.transcribe(file_path)
if transcription: if transcription:
logger.info("Transcribed {}: {}...", media_type, transcription[:50]) logger.info("Transcribed {}: {}...", media_type, transcription[:50])
content_parts.append(f"[transcription: {transcription}]") content_parts.append(f"[transcription: {transcription}]")

View File

@@ -36,6 +36,7 @@ class WecomChannel(BaseChannel):
""" """
name = "wecom" name = "wecom"
display_name = "WeCom"
def __init__(self, config: WecomConfig, bus: MessageBus): def __init__(self, config: WecomConfig, bus: MessageBus):
super().__init__(config, bus) super().__init__(config, bus)

View File

@@ -22,6 +22,7 @@ class WhatsAppChannel(BaseChannel):
""" """
name = "whatsapp" name = "whatsapp"
display_name = "WhatsApp"
def __init__(self, config: WhatsAppConfig, bus: MessageBus): def __init__(self, config: WhatsAppConfig, bus: MessageBus):
super().__init__(config, bus) super().__init__(config, bus)

View File

@@ -683,6 +683,7 @@ app.add_typer(channels_app, name="channels")
@channels_app.command("status") @channels_app.command("status")
def channels_status(): def channels_status():
"""Show channel status.""" """Show channel status."""
from nanobot.channels.registry import discover_channel_names, load_channel_class
from nanobot.config.loader import load_config from nanobot.config.loader import load_config
config = load_config() config = load_config()
@@ -690,84 +691,18 @@ def channels_status():
table = Table(title="Channel Status") table = Table(title="Channel Status")
table.add_column("Channel", style="cyan") table.add_column("Channel", style="cyan")
table.add_column("Enabled", style="green") table.add_column("Enabled", style="green")
table.add_column("Configuration", style="yellow")
# WhatsApp for modname in sorted(discover_channel_names()):
wa = config.channels.whatsapp 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( table.add_row(
"WhatsApp", display,
"" if wa.enabled else "", "[green]\u2713[/green]" if enabled else "[dim]\u2717[/dim]",
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
) )
console.print(table) console.print(table)