Files
nanobot/nanobot/channels/base.py
T

342 lines
12 KiB
Python
Raw Normal View History

2026-02-01 07:36:42 +00:00
"""Base channel interface for chat platforms."""
from __future__ import annotations
2026-02-01 07:36:42 +00:00
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, cast
2026-02-01 07:36:42 +00:00
from loguru import logger
2026-08-06 18:29:04 +08:00
from nanobot.bus.events import InboundMessage, OutboundMessage
2026-02-01 07:36:42 +00:00
from nanobot.bus.queue import MessageBus
from nanobot.pairing import (
PAIRING_CODE_META_KEY,
format_pairing_reply,
generate_code,
is_approved,
)
2026-02-01 07:36:42 +00:00
class BaseChannel(ABC):
"""
Abstract base class for chat channel implementations.
2026-02-01 07:36:42 +00:00
Each channel (Telegram, Discord, etc.) should implement this interface
to integrate with the nanobot message bus.
"""
2026-02-01 07:36:42 +00:00
name: str = "base"
display_name: str = "Base"
send_progress: bool = True
send_tool_hints: bool = True
show_reasoning: bool = True
2026-02-01 07:36:42 +00:00
def __init__(self, config: Any, bus: MessageBus):
"""
Initialize the channel.
2026-02-01 07:36:42 +00:00
Args:
config: Channel-specific configuration.
bus: The message bus for communication.
"""
self.config = config
self.logger = logger.bind(channel=self.name)
2026-02-01 07:36:42 +00:00
self.bus = bus
self._running = False
async def transcribe_audio(self, file_path: str | Path) -> str:
"""Transcribe an audio file via Whisper (OpenAI or Groq). Returns empty string on failure."""
try:
from nanobot.audio.transcription import (
resolve_transcription_config,
transcribe_audio_file,
)
from nanobot.config.loader import load_config
return await transcribe_audio_file(file_path, resolve_transcription_config(load_config()))
except Exception:
self.logger.exception("Audio transcription failed")
return ""
async def login(self, force: bool = False) -> bool:
"""
Perform channel-specific interactive login (e.g. QR code scan).
Args:
force: If True, ignore existing credentials and force re-authentication.
Returns True if already authenticated or login succeeds.
Override in subclasses that support interactive login.
"""
return True
2026-02-01 07:36:42 +00:00
@abstractmethod
async def start(self) -> None:
"""
Start the channel and begin listening for messages.
2026-02-01 07:36:42 +00:00
This should be a long-running async task that:
1. Connects to the chat platform
2. Listens for incoming messages
3. Forwards messages to the bus via _handle_message()
"""
pass
2026-02-01 07:36:42 +00:00
@abstractmethod
async def stop(self) -> None:
"""Stop the channel and clean up resources."""
pass
2026-02-01 07:36:42 +00:00
@abstractmethod
async def send(self, msg: OutboundMessage) -> None:
"""
Send a message through this channel.
2026-02-01 07:36:42 +00:00
Args:
msg: The message to send.
Implementations should raise on delivery failure so the channel manager
can apply any retry policy in one place.
2026-02-01 07:36:42 +00:00
"""
pass
def progress_transport_defaults(self) -> tuple[bool, bool] | None:
"""Return channel-owned defaults for progress and tool-hint messages.
``None`` keeps the global channel policy. Channels should override this
only when their transport requires different defaults.
"""
return None
def should_retry_send_error(self, error: Exception) -> bool:
"""Return whether the channel manager may retry a failed delivery.
Channels with protocol-level business errors can override this hook to
prevent retries that cannot succeed until external state changes.
Transport and unexpected errors remain retryable by default.
"""
return True
def start_error_message(self, error: Exception) -> str | None:
"""Return an actionable public message for a channel startup failure.
Channel-specific exception handling stays in the owning channel. Returning
``None`` keeps the manager's generic fallback.
"""
return None
2026-06-30 00:03:07 +08:00
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
merge_next: bool = False,
2026-06-30 00:03:07 +08:00
) -> None:
"""Deliver a streaming text chunk.
Override in subclasses to enable streaming. Implementations should
raise on delivery failure so the channel manager can retry.
2026-03-26 02:35:12 +00:00
2026-06-30 00:03:07 +08:00
Stateful implementations should key buffers by ``stream_id`` rather
than only by ``chat_id`` when it is provided.
``merge_next`` marks a resumable provider boundary whose next text
segment belongs to the same user-visible message.
"""
pass
async def send_reasoning_delta(
2026-06-30 00:03:07 +08:00
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Stream a chunk of model reasoning/thinking content.
Default is no-op. Channels with a native low-emphasis primitive
(Slack context block, Telegram expandable blockquote, Discord
subtext, WebUI italic bubble, ...) override to render reasoning
as a subordinate trace that updates in place as the model thinks.
2026-06-30 00:03:07 +08:00
Streaming contract mirrors :meth:`send_delta`: stateful implementations
should key buffers by ``stream_id`` rather than only by ``chat_id``.
"""
return
async def send_reasoning_end(
2026-06-30 00:03:07 +08:00
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
"""Mark the end of a reasoning stream segment.
Default is no-op. Channels that buffer ``send_reasoning_delta``
chunks for in-place updates use this signal to flush and freeze
the rendered group; one-shot channels can ignore it entirely.
"""
return
async def send_file_edit_events(
self,
chat_id: str,
edits: list[dict[str, Any]],
metadata: dict[str, Any] | None = None,
) -> None:
"""Deliver structured live file-edit events.
Default is no-op. Channels with a rich activity surface can override
this to render editing progress without receiving empty text messages.
"""
return
async def send_reasoning(self, msg: OutboundMessage) -> None:
"""Deliver a complete reasoning block.
Default implementation reuses the streaming pair so plugins only
need to override the delta/end methods. Equivalent to one delta
with the full content followed immediately by an end marker —
keeps a single rendering path for both streamed and one-shot
reasoning (e.g. DeepSeek-R1's final-response ``reasoning_content``).
"""
if not msg.content:
return
2026-06-30 00:03:07 +08:00
stream_id = getattr(msg.event, "stream_id", None)
await self.send_reasoning_delta(
msg.chat_id,
msg.content,
msg.metadata,
stream_id=stream_id,
)
await self.send_reasoning_end(
msg.chat_id,
msg.metadata,
stream_id=stream_id,
)
@property
def supports_streaming(self) -> bool:
"""True when config enables streaming AND this subclass implements send_delta."""
cfg = self.config
config_mapping = cast(dict[str, Any], cfg) if isinstance(cfg, dict) else None
streaming: Any = (
config_mapping.get("streaming", False)
if config_mapping is not None
else getattr(cast(Any, cfg), "streaming", False)
)
return bool(streaming) and type(self).send_delta is not BaseChannel.send_delta
2026-02-01 07:36:42 +00:00
def is_allowed(self, sender_id: str) -> bool:
"""Check sender permission: star > allowlist > pairing store > deny."""
if isinstance(self.config, dict):
config_mapping = cast(dict[str, Any], self.config)
allow_list: Any = (
config_mapping.get("allow_from") or config_mapping.get("allowFrom") or []
)
else:
allow_list = getattr(self.config, "allow_from", None) or []
if "*" in allow_list:
return True
# allowFrom entries are opaque tokens — must match exactly.
if str(sender_id) in allow_list:
return True
if is_approved(self.name, str(sender_id)):
return True
return False
2026-02-01 07:36:42 +00:00
async def _handle_message(
self,
sender_id: str,
chat_id: str,
content: str,
media: list[str] | None = None,
metadata: dict[str, Any] | None = None,
session_key: str | None = None,
is_dm: bool = False,
authorization_id: str | None = None,
2026-08-06 18:29:04 +08:00
require_existing_session: bool = False,
2026-02-01 07:36:42 +00:00
) -> None:
"""Handle a message after checking its authorization subject.
``sender_id`` is the identity recorded on the inbound message. Channels
where access is scoped to another entity (for example, a group or room)
can pass that entity as ``authorization_id`` without changing the
sender's identity. When omitted, authorization remains sender-based.
"""
permission_id = authorization_id if authorization_id is not None else sender_id
if not self.is_allowed(permission_id):
if is_dm:
try:
code = generate_code(self.name, str(sender_id))
except OSError:
# Transient pairing-store I/O failure: skip the pairing
# reply for this message rather than crash the handler.
self.logger.warning(
"Pairing store unavailable; dropping DM from {}", sender_id
)
return
await self.send(
OutboundMessage(
channel=self.name,
chat_id=str(chat_id),
content=format_pairing_reply(code),
metadata={PAIRING_CODE_META_KEY: code},
)
)
self.logger.info(
"Sent pairing code {} to sender {} in chat {}",
code, sender_id, chat_id,
)
else:
self.logger.warning(
"Access denied for sender {}. "
"Add them to allowFrom list in config to grant access.",
sender_id,
)
return
2026-08-06 18:29:04 +08:00
meta = metadata or {}
if self.supports_streaming:
meta = {**meta, "_wants_stream": True}
2026-02-01 07:36:42 +00:00
msg = InboundMessage(
channel=self.name,
sender_id=str(sender_id),
chat_id=str(chat_id),
content=content,
media=media or [],
metadata=meta,
session_key_override=session_key,
2026-08-06 18:29:04 +08:00
require_existing_session=require_existing_session,
2026-02-01 07:36:42 +00:00
)
2026-02-01 07:36:42 +00:00
await self.bus.publish_inbound(msg)
@classmethod
def default_config(cls) -> dict[str, Any]:
"""Return default config for onboard. Override in plugins to auto-populate config.json."""
return {"enabled": False}
@classmethod
def refresh_feature_metadata(
cls,
config_path: Path,
*,
instance_id: str = "default",
) -> bool:
"""Refresh persisted display metadata after an explicit settings action."""
return False
2026-02-01 07:36:42 +00:00
@property
def is_running(self) -> bool:
"""Check if the channel is running."""
return self._running