diff --git a/README.md b/README.md index 74c24d9..55dc7fa 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ nanobot agent -m "Hello from my local LLM!" ## 💬 Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat — anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Mochat — anytime, anywhere. | Channel | Setup | |---------|-------| @@ -172,7 +172,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Medium (scan QR) | | **Feishu** | Medium (app credentials) | -| **Moltchat** | Medium (claw token + websocket) | +| **Mochat** | Medium (claw token + websocket) |
Telegram (Recommended) @@ -207,7 +207,7 @@ nanobot gateway
-Moltchat (Claw IM) +Mochat (Claw IM) Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. @@ -221,7 +221,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. ```json { "channels": { - "moltchat": { + "mochat": { "enabled": true, "baseUrl": "https://mochat.io", "socketUrl": "https://mochat.io", @@ -244,7 +244,7 @@ nanobot gateway ``` > [!TIP] -> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Moltchat API endpoint. +> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint.
@@ -456,7 +456,7 @@ docker run -v ~/.nanobot:/root/.nanobot --rm nanobot onboard # Edit config on host to add API keys vim ~/.nanobot/config.json -# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Moltchat) +# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat) docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway # Or run a single command diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py index 4d77063..034d401 100644 --- a/nanobot/channels/__init__.py +++ b/nanobot/channels/__init__.py @@ -2,6 +2,6 @@ from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager -from nanobot.channels.moltchat import MoltchatChannel +from nanobot.channels.mochat import MochatChannel -__all__ = ["BaseChannel", "ChannelManager", "MoltchatChannel"] +__all__ = ["BaseChannel", "ChannelManager", "MochatChannel"] diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 11690ef..64214ce 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -78,17 +78,17 @@ class ChannelManager: except ImportError as e: logger.warning(f"Feishu channel not available: {e}") - # Moltchat channel - if self.config.channels.moltchat.enabled: + # Mochat channel + if self.config.channels.mochat.enabled: try: - from nanobot.channels.moltchat import MoltchatChannel + from nanobot.channels.mochat import MochatChannel - self.channels["moltchat"] = MoltchatChannel( - self.config.channels.moltchat, self.bus + self.channels["mochat"] = MochatChannel( + self.config.channels.mochat, self.bus ) - logger.info("Moltchat channel enabled") + logger.info("Mochat channel enabled") except ImportError as e: - logger.warning(f"Moltchat channel not available: {e}") + logger.warning(f"Mochat channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/channels/moltchat.py b/nanobot/channels/mochat.py similarity index 92% rename from nanobot/channels/moltchat.py rename to nanobot/channels/mochat.py index cc590d4..6569cdd 100644 --- a/nanobot/channels/moltchat.py +++ b/nanobot/channels/mochat.py @@ -1,4 +1,4 @@ -"""Moltchat channel implementation using Socket.IO with HTTP polling fallback.""" +"""Mochat channel implementation using Socket.IO with HTTP polling fallback.""" from __future__ import annotations @@ -15,7 +15,7 @@ from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel -from nanobot.config.schema import MoltchatConfig +from nanobot.config.schema import MochatConfig from nanobot.utils.helpers import get_data_path try: @@ -39,7 +39,7 @@ CURSOR_SAVE_DEBOUNCE_S = 0.5 @dataclass -class MoltchatBufferedEntry: +class MochatBufferedEntry: """Buffered inbound entry for delayed dispatch.""" raw_body: str @@ -55,20 +55,20 @@ class MoltchatBufferedEntry: class DelayState: """Per-target delayed message state.""" - entries: list[MoltchatBufferedEntry] = field(default_factory=list) + entries: list[MochatBufferedEntry] = field(default_factory=list) lock: asyncio.Lock = field(default_factory=asyncio.Lock) timer: asyncio.Task | None = None @dataclass -class MoltchatTarget: +class MochatTarget: """Outbound target resolution result.""" id: str is_panel: bool -def normalize_moltchat_content(content: Any) -> str: +def normalize_mochat_content(content: Any) -> str: """Normalize content payload to text.""" if isinstance(content, str): return content.strip() @@ -80,17 +80,17 @@ def normalize_moltchat_content(content: Any) -> str: return str(content) -def resolve_moltchat_target(raw: str) -> MoltchatTarget: +def resolve_mochat_target(raw: str) -> MochatTarget: """Resolve id and target kind from user-provided target string.""" trimmed = (raw or "").strip() if not trimmed: - return MoltchatTarget(id="", is_panel=False) + return MochatTarget(id="", is_panel=False) lowered = trimmed.lower() cleaned = trimmed forced_panel = False - prefixes = ["moltchat:", "mochat:", "group:", "channel:", "panel:"] + prefixes = ["mochat:", "group:", "channel:", "panel:"] for prefix in prefixes: if lowered.startswith(prefix): cleaned = trimmed[len(prefix) :].strip() @@ -99,9 +99,9 @@ def resolve_moltchat_target(raw: str) -> MoltchatTarget: break if not cleaned: - return MoltchatTarget(id="", is_panel=False) + return MochatTarget(id="", is_panel=False) - return MoltchatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_")) + return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_")) def extract_mention_ids(value: Any) -> list[str]: @@ -152,7 +152,7 @@ def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool: def resolve_require_mention( - config: MoltchatConfig, + config: MochatConfig, session_id: str, group_id: str, ) -> bool: @@ -167,7 +167,7 @@ def resolve_require_mention( return bool(config.mention.require_in_groups) -def build_buffered_body(entries: list[MoltchatBufferedEntry], is_group: bool) -> str: +def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> str: """Build text body from one or more buffered entries.""" if not entries: return "" @@ -200,20 +200,20 @@ def parse_timestamp(value: Any) -> int | None: return None -class MoltchatChannel(BaseChannel): - """Moltchat channel using socket.io with fallback polling workers.""" +class MochatChannel(BaseChannel): + """Mochat channel using socket.io with fallback polling workers.""" - name = "moltchat" + name = "mochat" - def __init__(self, config: MoltchatConfig, bus: MessageBus): + def __init__(self, config: MochatConfig, bus: MessageBus): super().__init__(config, bus) - self.config: MoltchatConfig = config + self.config: MochatConfig = config self._http: httpx.AsyncClient | None = None self._socket: Any = None self._ws_connected = False self._ws_ready = False - self._state_dir = get_data_path() / "moltchat" + self._state_dir = get_data_path() / "mochat" self._cursor_path = self._state_dir / "session_cursors.json" self._session_cursor: dict[str, int] = {} self._cursor_save_task: asyncio.Task | None = None @@ -239,9 +239,9 @@ class MoltchatChannel(BaseChannel): self._target_locks: dict[str, asyncio.Lock] = {} async def start(self) -> None: - """Start Moltchat channel workers and websocket connection.""" + """Start Mochat channel workers and websocket connection.""" if not self.config.claw_token: - logger.error("Moltchat claw_token not configured") + logger.error("Mochat claw_token not configured") return self._running = True @@ -296,7 +296,7 @@ class MoltchatChannel(BaseChannel): async def send(self, msg: OutboundMessage) -> None: """Send outbound message to session or panel.""" if not self.config.claw_token: - logger.warning("Moltchat claw_token missing, skip send") + logger.warning("Mochat claw_token missing, skip send") return content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else [] @@ -306,9 +306,9 @@ class MoltchatChannel(BaseChannel): if not content: return - target = resolve_moltchat_target(msg.chat_id) + target = resolve_mochat_target(msg.chat_id) if not target.id: - logger.warning("Moltchat outbound target is empty") + logger.warning("Mochat outbound target is empty") return is_panel = target.is_panel or target.id in self._panel_set @@ -330,7 +330,7 @@ class MoltchatChannel(BaseChannel): reply_to=msg.reply_to, ) except Exception as e: - logger.error(f"Failed to send Moltchat message: {e}") + logger.error(f"Failed to send Mochat message: {e}") def _seed_targets_from_config(self) -> None: sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions) @@ -351,7 +351,7 @@ class MoltchatChannel(BaseChannel): async def _start_socket_client(self) -> bool: if not SOCKETIO_AVAILABLE: - logger.warning("python-socketio not installed, Moltchat using polling fallback") + logger.warning("python-socketio not installed, Mochat using polling fallback") return False serializer = "default" @@ -385,7 +385,7 @@ class MoltchatChannel(BaseChannel): async def connect() -> None: self._ws_connected = True self._ws_ready = False - logger.info("Moltchat websocket connected") + logger.info("Mochat websocket connected") subscribed = await self._subscribe_all() self._ws_ready = subscribed @@ -400,13 +400,13 @@ class MoltchatChannel(BaseChannel): return self._ws_connected = False self._ws_ready = False - logger.warning("Moltchat websocket disconnected") + logger.warning("Mochat websocket disconnected") await self._ensure_fallback_workers() @client.event async def connect_error(data: Any) -> None: message = str(data) - logger.error(f"Moltchat websocket connect error: {message}") + logger.error(f"Mochat websocket connect error: {message}") @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: @@ -441,7 +441,7 @@ class MoltchatChannel(BaseChannel): ) return True except Exception as e: - logger.error(f"Failed to connect Moltchat websocket: {e}") + logger.error(f"Failed to connect Mochat websocket: {e}") try: await client.disconnect() except Exception: @@ -486,7 +486,7 @@ class MoltchatChannel(BaseChannel): }, ) if not ack.get("result"): - logger.error(f"Moltchat subscribeSessions failed: {ack.get('message', 'unknown error')}") + logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}") return False data = ack.get("data") @@ -516,7 +516,7 @@ class MoltchatChannel(BaseChannel): }, ) if not ack.get("result"): - logger.error(f"Moltchat subscribePanels failed: {ack.get('message', 'unknown error')}") + logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}") return False return True @@ -544,7 +544,7 @@ class MoltchatChannel(BaseChannel): try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: - logger.warning(f"Moltchat refresh failed: {e}") + logger.warning(f"Mochat refresh failed: {e}") if self._fallback_mode: await self._ensure_fallback_workers() @@ -560,7 +560,7 @@ class MoltchatChannel(BaseChannel): try: response = await self._list_sessions() except Exception as e: - logger.warning(f"Moltchat listSessions failed: {e}") + logger.warning(f"Mochat listSessions failed: {e}") return sessions = response.get("sessions") @@ -599,7 +599,7 @@ class MoltchatChannel(BaseChannel): try: response = await self._get_workspace_group() except Exception as e: - logger.warning(f"Moltchat getWorkspaceGroup failed: {e}") + logger.warning(f"Mochat getWorkspaceGroup failed: {e}") return raw_panels = response.get("panels") @@ -683,7 +683,7 @@ class MoltchatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Moltchat watch fallback error ({session_id}): {e}") + logger.warning(f"Mochat watch fallback error ({session_id}): {e}") await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) async def _panel_poll_worker(self, panel_id: str) -> None: @@ -723,7 +723,7 @@ class MoltchatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Moltchat panel polling error ({panel_id}): {e}") + logger.warning(f"Mochat panel polling error ({panel_id}): {e}") await asyncio.sleep(sleep_s) @@ -803,7 +803,7 @@ class MoltchatChannel(BaseChannel): if message_id and self._remember_message_id(seen_key, message_id): return - raw_body = normalize_moltchat_content(payload.get("content")) + raw_body = normalize_mochat_content(payload.get("content")) if not raw_body: raw_body = "[empty message]" @@ -826,7 +826,7 @@ class MoltchatChannel(BaseChannel): if require_mention and not was_mentioned and not use_delay: return - entry = MoltchatBufferedEntry( + entry = MochatBufferedEntry( raw_body=raw_body, author=author, sender_name=sender_name, @@ -883,7 +883,7 @@ class MoltchatChannel(BaseChannel): key: str, target_id: str, target_kind: str, - entry: MoltchatBufferedEntry, + entry: MochatBufferedEntry, ) -> None: state = self._delay_states.setdefault(key, DelayState()) @@ -912,7 +912,7 @@ class MoltchatChannel(BaseChannel): target_id: str, target_kind: str, reason: str, - entry: MoltchatBufferedEntry | None, + entry: MochatBufferedEntry | None, ) -> None: state = self._delay_states.setdefault(key, DelayState()) @@ -944,7 +944,7 @@ class MoltchatChannel(BaseChannel): self, target_id: str, target_kind: str, - entries: list[MoltchatBufferedEntry], + entries: list[MochatBufferedEntry], was_mentioned: bool, ) -> None: if not entries: @@ -1092,7 +1092,7 @@ class MoltchatChannel(BaseChannel): try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: - logger.warning(f"Failed to read Moltchat cursor file: {e}") + logger.warning(f"Failed to read Mochat cursor file: {e}") return cursors = data.get("cursors") if isinstance(data, dict) else None @@ -1114,14 +1114,14 @@ class MoltchatChannel(BaseChannel): self._state_dir.mkdir(parents=True, exist_ok=True) self._cursor_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: - logger.warning(f"Failed to save Moltchat cursor file: {e}") + logger.warning(f"Failed to save Mochat cursor file: {e}") def _base_url(self) -> str: return self.config.base_url.strip().rstrip("/") async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: if not self._http: - raise RuntimeError("Moltchat HTTP client not initialized") + raise RuntimeError("Mochat HTTP client not initialized") url = f"{self._base_url()}{path}" response = await self._http.post( @@ -1135,7 +1135,7 @@ class MoltchatChannel(BaseChannel): text = response.text if not response.is_success: - raise RuntimeError(f"Moltchat HTTP {response.status_code}: {text[:200]}") + raise RuntimeError(f"Mochat HTTP {response.status_code}: {text[:200]}") parsed: Any try: @@ -1146,7 +1146,7 @@ class MoltchatChannel(BaseChannel): if isinstance(parsed, dict) and isinstance(parsed.get("code"), int): if parsed["code"] != 200: message = str(parsed.get("message") or parsed.get("name") or "request failed") - raise RuntimeError(f"Moltchat API error: {message} (code={parsed['code']})") + raise RuntimeError(f"Mochat API error: {message} (code={parsed['code']})") data = parsed.get("data") return data if isinstance(data, dict) else {} diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2039f82..3094aa1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -376,11 +376,11 @@ def channels_status(): fs_config ) - # Moltchat - mc = config.channels.moltchat + # Mochat + mc = config.channels.mochat mc_base = mc.base_url or "[dim]not configured[/dim]" table.add_row( - "Moltchat", + "Mochat", "✓" if mc.enabled else "✗", mc_base ) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4df4251..1d6ca9e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -39,18 +39,18 @@ class DiscordConfig(BaseModel): intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT -class MoltchatMentionConfig(BaseModel): - """Moltchat mention behavior configuration.""" +class MochatMentionConfig(BaseModel): + """Mochat mention behavior configuration.""" require_in_groups: bool = False -class MoltchatGroupRule(BaseModel): - """Moltchat per-group mention requirement.""" +class MochatGroupRule(BaseModel): + """Mochat per-group mention requirement.""" require_mention: bool = False -class MoltchatConfig(BaseModel): - """Moltchat channel configuration.""" +class MochatConfig(BaseModel): + """Mochat channel configuration.""" enabled: bool = False base_url: str = "http://localhost:11000" socket_url: str = "" @@ -69,8 +69,8 @@ class MoltchatConfig(BaseModel): sessions: list[str] = Field(default_factory=list) panels: list[str] = Field(default_factory=list) allow_from: list[str] = Field(default_factory=list) - mention: MoltchatMentionConfig = Field(default_factory=MoltchatMentionConfig) - groups: dict[str, MoltchatGroupRule] = Field(default_factory=dict) + mention: MochatMentionConfig = Field(default_factory=MochatMentionConfig) + groups: dict[str, MochatGroupRule] = Field(default_factory=dict) reply_delay_mode: str = "non-mention" # off | non-mention reply_delay_ms: int = 120000 @@ -81,7 +81,7 @@ class ChannelsConfig(BaseModel): telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) feishu: FeishuConfig = Field(default_factory=FeishuConfig) - moltchat: MoltchatConfig = Field(default_factory=MoltchatConfig) + mochat: MochatConfig = Field(default_factory=MochatConfig) class AgentDefaults(BaseModel): diff --git a/tests/test_moltchat_channel.py b/tests/test_mochat_channel.py similarity index 73% rename from tests/test_moltchat_channel.py rename to tests/test_mochat_channel.py index 1f65a68..4d73840 100644 --- a/tests/test_moltchat_channel.py +++ b/tests/test_mochat_channel.py @@ -1,27 +1,27 @@ import pytest from nanobot.bus.queue import MessageBus -from nanobot.channels.moltchat import ( - MoltchatBufferedEntry, - MoltchatChannel, +from nanobot.channels.mochat import ( + MochatBufferedEntry, + MochatChannel, build_buffered_body, - resolve_moltchat_target, + resolve_mochat_target, resolve_require_mention, resolve_was_mentioned, ) -from nanobot.config.schema import MoltchatConfig, MoltchatGroupRule, MoltchatMentionConfig +from nanobot.config.schema import MochatConfig, MochatGroupRule, MochatMentionConfig -def test_resolve_moltchat_target_prefixes() -> None: - t = resolve_moltchat_target("panel:abc") +def test_resolve_mochat_target_prefixes() -> None: + t = resolve_mochat_target("panel:abc") assert t.id == "abc" assert t.is_panel is True - t = resolve_moltchat_target("session_123") + t = resolve_mochat_target("session_123") assert t.id == "session_123" assert t.is_panel is False - t = resolve_moltchat_target("mochat:session_456") + t = resolve_mochat_target("mochat:session_456") assert t.id == "session_456" assert t.is_panel is False @@ -40,12 +40,12 @@ def test_resolve_was_mentioned_from_meta_and_text() -> None: def test_resolve_require_mention_priority() -> None: - cfg = MoltchatConfig( + cfg = MochatConfig( groups={ - "*": MoltchatGroupRule(require_mention=False), - "group-a": MoltchatGroupRule(require_mention=True), + "*": MochatGroupRule(require_mention=False), + "group-a": MochatGroupRule(require_mention=True), }, - mention=MoltchatMentionConfig(require_in_groups=False), + mention=MochatMentionConfig(require_in_groups=False), ) assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-a") is True @@ -55,14 +55,14 @@ def test_resolve_require_mention_priority() -> None: @pytest.mark.asyncio async def test_delay_buffer_flushes_on_mention() -> None: bus = MessageBus() - cfg = MoltchatConfig( + cfg = MochatConfig( enabled=True, claw_token="token", agent_user_id="bot", reply_delay_mode="non-mention", reply_delay_ms=60_000, ) - channel = MoltchatChannel(cfg, bus) + channel = MochatChannel(cfg, bus) first = { "type": "message.add", @@ -94,7 +94,7 @@ async def test_delay_buffer_flushes_on_mention() -> None: assert bus.inbound_size == 1 msg = await bus.consume_inbound() - assert msg.channel == "moltchat" + assert msg.channel == "mochat" assert msg.chat_id == "panel-1" assert "user1: first" in msg.content assert "user2: hello <@bot>" in msg.content @@ -106,8 +106,8 @@ async def test_delay_buffer_flushes_on_mention() -> None: def test_build_buffered_body_group_labels() -> None: body = build_buffered_body( entries=[ - MoltchatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"), - MoltchatBufferedEntry(raw_body="b", author="u2", sender_username="bot"), + MochatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"), + MochatBufferedEntry(raw_body="b", author="u2", sender_username="bot"), ], is_group=True, )