From 051e396a8a5d07340f00e8722b91f2a53e9c5c23 Mon Sep 17 00:00:00 2001 From: Kamal Date: Wed, 4 Feb 2026 23:26:20 +0530 Subject: [PATCH 01/58] feat: add Slack channel support --- nanobot/agent/loop.py | 3 +- nanobot/channels/manager.py | 11 ++ nanobot/channels/slack.py | 205 ++++++++++++++++++++++++++++++++++++ nanobot/cli/commands.py | 9 ++ nanobot/config/schema.py | 21 ++++ pyproject.toml | 1 + 6 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 nanobot/channels/slack.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index bfe6e89..ac24016 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -220,7 +220,8 @@ class AgentLoop: return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, - content=final_content + content=final_content, + metadata=msg.metadata or {}, ) async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 73c3334..d49d3b1 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -55,6 +55,17 @@ class ChannelManager: logger.info("WhatsApp channel enabled") except ImportError as e: logger.warning(f"WhatsApp 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(f"Slack channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py new file mode 100644 index 0000000..32abe3b --- /dev/null +++ b/nanobot/channels/slack.py @@ -0,0 +1,205 @@ +"""Slack channel implementation using Socket Mode.""" + +import asyncio +import re +from typing import Any + +from loguru import logger +from slack_sdk.socket_mode.aiohttp import SocketModeClient +from slack_sdk.socket_mode.request import SocketModeRequest +from slack_sdk.socket_mode.response import SocketModeResponse +from slack_sdk.web.async_client import AsyncWebClient + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import SlackConfig + + +class SlackChannel(BaseChannel): + """Slack channel using Socket Mode.""" + + name = "slack" + + def __init__(self, config: SlackConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: SlackConfig = config + self._web_client: AsyncWebClient | None = None + self._socket_client: SocketModeClient | None = None + self._bot_user_id: str | None = None + + async def start(self) -> None: + """Start the Slack Socket Mode client.""" + if not self.config.bot_token or not self.config.app_token: + logger.error("Slack bot/app token not configured") + return + if self.config.mode != "socket": + logger.error(f"Unsupported Slack mode: {self.config.mode}") + return + + self._running = True + + self._web_client = AsyncWebClient(token=self.config.bot_token) + self._socket_client = SocketModeClient( + app_token=self.config.app_token, + web_client=self._web_client, + ) + + self._socket_client.socket_mode_request_listeners.append(self._on_socket_request) + + # Resolve bot user ID for mention handling + try: + auth = await self._web_client.auth_test() + self._bot_user_id = auth.get("user_id") + logger.info(f"Slack bot connected as {self._bot_user_id}") + except Exception as e: + logger.warning(f"Slack auth_test failed: {e}") + + logger.info("Starting Slack Socket Mode client...") + await self._socket_client.connect() + + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop the Slack client.""" + self._running = False + if self._socket_client: + try: + await self._socket_client.close() + except Exception as e: + logger.warning(f"Slack socket close failed: {e}") + self._socket_client = None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through Slack.""" + if not self._web_client: + logger.warning("Slack client not running") + return + try: + slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} + thread_ts = slack_meta.get("thread_ts") + channel_type = slack_meta.get("channel_type") + # Only reply in thread for channel/group messages; DMs don't use threads + use_thread = thread_ts and channel_type != "im" + await self._web_client.chat_postMessage( + channel=msg.chat_id, + text=msg.content or "", + thread_ts=thread_ts if use_thread else None, + ) + except Exception as e: + logger.error(f"Error sending Slack message: {e}") + + async def _on_socket_request( + self, + client: SocketModeClient, + req: SocketModeRequest, + ) -> None: + """Handle incoming Socket Mode requests.""" + if req.type != "events_api": + return + + # Acknowledge right away + await client.send_socket_mode_response( + SocketModeResponse(envelope_id=req.envelope_id) + ) + + payload = req.payload or {} + event = payload.get("event") or {} + event_type = event.get("type") + + # Handle app mentions or plain messages + if event_type not in ("message", "app_mention"): + return + + sender_id = event.get("user") + chat_id = event.get("channel") + + # Ignore bot/system messages to prevent loops + if event.get("subtype") == "bot_message" or event.get("subtype"): + return + if self._bot_user_id and sender_id == self._bot_user_id: + return + + # Avoid double-processing: Slack sends both `message` and `app_mention` + # for mentions in channels. Prefer `app_mention`. + text = event.get("text") or "" + if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text: + return + + # Debug: log basic event shape + logger.debug( + "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}", + event_type, + event.get("subtype"), + sender_id, + chat_id, + event.get("channel_type"), + text[:80], + ) + if not sender_id or not chat_id: + return + + channel_type = event.get("channel_type") or "" + + if not self._is_allowed(sender_id, chat_id, channel_type): + return + + if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id): + return + + text = self._strip_bot_mention(text) + + thread_ts = event.get("thread_ts") or event.get("ts") + # Add :eyes: reaction to the triggering message (best-effort) + try: + if self._web_client and event.get("ts"): + await self._web_client.reactions_add( + channel=chat_id, + name="eyes", + timestamp=event.get("ts"), + ) + except Exception as e: + logger.debug(f"Slack reactions_add failed: {e}") + + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=text, + metadata={ + "slack": { + "event": event, + "thread_ts": thread_ts, + "channel_type": channel_type, + } + }, + ) + + def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool: + if channel_type == "im": + if not self.config.dm.enabled: + return False + if self.config.dm.policy == "allowlist": + return sender_id in self.config.dm.allow_from + return True + + # Group / channel messages + if self.config.group_policy == "allowlist": + return chat_id in self.config.group_allow_from + return True + + def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool: + if self.config.group_policy == "open": + return True + if self.config.group_policy == "mention": + if event_type == "app_mention": + return True + return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text + if self.config.group_policy == "allowlist": + return chat_id in self.config.group_allow_from + return False + + def _strip_bot_mention(self, text: str) -> str: + if not text or not self._bot_user_id: + return text + return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip() diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c2241fb..1dd91a9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -379,6 +379,15 @@ def channels_status(): 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 + ) + console.print(table) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4c34834..3575454 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -19,10 +19,31 @@ class TelegramConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames +class SlackDMConfig(BaseModel): + """Slack DM policy configuration.""" + enabled: bool = True + policy: str = "open" # "open" or "allowlist" + allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs + + +class SlackConfig(BaseModel): + """Slack channel configuration.""" + enabled: bool = False + mode: str = "socket" # "socket" supported + webhook_path: str = "/slack/events" + bot_token: str = "" # xoxb-... + app_token: str = "" # xapp-... + user_token_read_only: bool = True + group_policy: str = "open" # "open", "mention", "allowlist" + group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist + dm: SlackDMConfig = Field(default_factory=SlackDMConfig) + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) + slack: SlackConfig = Field(default_factory=SlackConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index d578a08..5d4dec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "rich>=13.0.0", "croniter>=2.0.0", "python-telegram-bot>=21.0", + "slack-sdk>=3.26.0", ] [project.optional-dependencies] From d7b72c8f83b105674852728bea9fb534166579c0 Mon Sep 17 00:00:00 2001 From: cwu Date: Fri, 6 Feb 2026 12:24:11 -0500 Subject: [PATCH 02/58] Drop unsupported parameters for providers. --- nanobot/providers/litellm_provider.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 2125b15..b227393 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -64,6 +64,8 @@ class LiteLLMProvider(LLMProvider): # Disable LiteLLM logging noise litellm.suppress_debug_info = True + # Drop unsupported parameters for providers (e.g., gpt-5 rejects some params) + litellm.drop_params = True async def chat( self, From cfe43e49200b809c4b7fc7e9db6ac4912cf23a88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?= Date: Sat, 7 Feb 2026 11:03:34 +0800 Subject: [PATCH 03/58] feat(email): add consent-gated IMAP/SMTP email channel --- EMAIL_ASSISTANT_E2E_GUIDE.md | 164 ++++++++++++++ nanobot/channels/email.py | 399 +++++++++++++++++++++++++++++++++++ nanobot/channels/manager.py | 11 + nanobot/config/schema.py | 31 +++ tests/test_email_channel.py | 311 +++++++++++++++++++++++++++ 5 files changed, 916 insertions(+) create mode 100644 EMAIL_ASSISTANT_E2E_GUIDE.md create mode 100644 nanobot/channels/email.py create mode 100644 tests/test_email_channel.py diff --git a/EMAIL_ASSISTANT_E2E_GUIDE.md b/EMAIL_ASSISTANT_E2E_GUIDE.md new file mode 100644 index 0000000..a72a18c --- /dev/null +++ b/EMAIL_ASSISTANT_E2E_GUIDE.md @@ -0,0 +1,164 @@ +# Nanobot Email Assistant: End-to-End Guide + +This guide explains how to run nanobot as a real email assistant with explicit user permission and optional automatic replies. + +## 1. What This Feature Does + +- Read unread emails via IMAP. +- Let the agent analyze/respond to email content. +- Send replies via SMTP. +- Enforce explicit owner consent before mailbox access. +- Let you toggle automatic replies on or off. + +## 2. Permission Model (Required) + +`channels.email.consentGranted` is the hard permission gate. + +- `false`: nanobot must not access mailbox content and must not send email. +- `true`: nanobot may read/send based on other settings. + +Only set `consentGranted: true` after the mailbox owner explicitly agrees. + +## 3. Auto-Reply Mode + +`channels.email.autoReplyEnabled` controls outbound automatic email replies. + +- `true`: inbound emails can receive automatic agent replies. +- `false`: inbound emails can still be read/processed, but automatic replies are skipped. + +Use `autoReplyEnabled: false` when you want analysis-only mode. + +## 4. Required Account Setup (Gmail Example) + +1. Enable 2-Step Verification in Google account security settings. +2. Create an App Password. +3. Use this app password for both IMAP and SMTP auth. + +Recommended servers: +- IMAP host/port: `imap.gmail.com:993` (SSL) +- SMTP host/port: `smtp.gmail.com:587` (STARTTLS) + +## 5. Config Example + +Edit `~/.nanobot/config.json`: + +```json +{ + "channels": { + "email": { + "enabled": true, + "consentGranted": true, + "imapHost": "imap.gmail.com", + "imapPort": 993, + "imapUsername": "you@gmail.com", + "imapPassword": "${NANOBOT_EMAIL_IMAP_PASSWORD}", + "imapMailbox": "INBOX", + "imapUseSsl": true, + "smtpHost": "smtp.gmail.com", + "smtpPort": 587, + "smtpUsername": "you@gmail.com", + "smtpPassword": "${NANOBOT_EMAIL_SMTP_PASSWORD}", + "smtpUseTls": true, + "smtpUseSsl": false, + "fromAddress": "you@gmail.com", + "autoReplyEnabled": true, + "pollIntervalSeconds": 30, + "markSeen": true, + "allowFrom": ["trusted.sender@example.com"] + } + } +} +``` + +## 6. Set Secrets via Environment Variables + +In the same shell before starting gateway: + +```bash +read -s "NANOBOT_EMAIL_IMAP_PASSWORD?IMAP app password: " +echo +read -s "NANOBOT_EMAIL_SMTP_PASSWORD?SMTP app password: " +echo +export NANOBOT_EMAIL_IMAP_PASSWORD +export NANOBOT_EMAIL_SMTP_PASSWORD +``` + +If you use one app password for both, enter the same value twice. + +## 7. Run and Verify + +Start: + +```bash +cd /Users/kaijimima1234/Desktop/nanobot +PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot gateway +``` + +Check channel status: + +```bash +PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot channels status +``` + +Expected behavior: +- `enabled=true + consentGranted=true + autoReplyEnabled=true`: read + auto reply. +- `enabled=true + consentGranted=true + autoReplyEnabled=false`: read only, no auto reply. +- `consentGranted=false`: no read, no send. + +## 8. Commands You Can Tell Nanobot + +Once gateway is running and email consent is enabled: + +1. Summarize yesterday's emails: + +```text +summarize my yesterday email +``` + +or + +```text +!email summary yesterday +``` + +2. Send an email to a friend: + +```text +!email send friend@example.com | Subject here | Body here +``` + +or + +```text +send email to friend@example.com subject: Subject here body: Body here +``` + +Notes: +- Sending command always performs a direct send (manual action by you). +- If `consentGranted` is `false`, send/read are blocked. +- If `autoReplyEnabled` is `false`, automatic replies are disabled, but direct send command above still works. + +## 9. End-to-End Test Plan + +1. Send a test email from an allowed sender to your mailbox. +2. Confirm nanobot receives and processes it. +3. If `autoReplyEnabled=true`, confirm a reply is delivered. +4. Set `autoReplyEnabled=false`, send another test email. +5. Confirm no auto-reply is sent. +6. Set `consentGranted=false`, send another test email. +7. Confirm nanobot does not read/send. + +## 10. Security Notes + +- Never commit real passwords/tokens into git. +- Prefer environment variables for secrets. +- Keep `allowFrom` restricted whenever possible. +- Rotate app passwords immediately if leaked. + +## 11. PR Checklist + +- [ ] `consentGranted` gating works for read/send. +- [ ] `autoReplyEnabled` toggle works as documented. +- [ ] README updated with new fields. +- [ ] Tests pass (`pytest`). +- [ ] No real credentials in tracked files. diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py new file mode 100644 index 0000000..029c00d --- /dev/null +++ b/nanobot/channels/email.py @@ -0,0 +1,399 @@ +"""Email channel implementation using IMAP polling + SMTP replies.""" + +import asyncio +import html +import imaplib +import re +import smtplib +import ssl +from datetime import date +from email import policy +from email.header import decode_header, make_header +from email.message import EmailMessage +from email.parser import BytesParser +from email.utils import parseaddr +from typing import Any + +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 EmailConfig + + +class EmailChannel(BaseChannel): + """ + Email channel. + + Inbound: + - Poll IMAP mailbox for unread messages. + - Convert each message into an inbound event. + + Outbound: + - Send responses via SMTP back to the sender address. + """ + + name = "email" + _IMAP_MONTHS = ( + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ) + + def __init__(self, config: EmailConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: EmailConfig = config + self._last_subject_by_chat: dict[str, str] = {} + self._last_message_id_by_chat: dict[str, str] = {} + self._processed_uids: set[str] = set() + + async def start(self) -> None: + """Start polling IMAP for inbound emails.""" + if not self.config.consent_granted: + logger.warning( + "Email channel disabled: consent_granted is false. " + "Set channels.email.consentGranted=true after explicit user permission." + ) + return + + if not self._validate_config(): + return + + self._running = True + logger.info("Starting Email channel (IMAP polling mode)...") + + poll_seconds = max(5, int(self.config.poll_interval_seconds)) + while self._running: + try: + inbound_items = await asyncio.to_thread(self._fetch_new_messages) + for item in inbound_items: + sender = item["sender"] + subject = item.get("subject", "") + message_id = item.get("message_id", "") + + if subject: + self._last_subject_by_chat[sender] = subject + if message_id: + self._last_message_id_by_chat[sender] = message_id + + await self._handle_message( + sender_id=sender, + chat_id=sender, + content=item["content"], + metadata=item.get("metadata", {}), + ) + except Exception as e: + logger.error(f"Email polling error: {e}") + + await asyncio.sleep(poll_seconds) + + async def stop(self) -> None: + """Stop polling loop.""" + self._running = False + + async def send(self, msg: OutboundMessage) -> None: + """Send email via SMTP.""" + if not self.config.consent_granted: + logger.warning("Skip email send: consent_granted is false") + return + + force_send = bool((msg.metadata or {}).get("force_send")) + if not self.config.auto_reply_enabled and not force_send: + logger.info("Skip automatic email reply: auto_reply_enabled is false") + return + + if not self.config.smtp_host: + logger.warning("Email channel SMTP host not configured") + return + + to_addr = msg.chat_id.strip() + if not to_addr: + logger.warning("Email channel missing recipient address") + return + + base_subject = self._last_subject_by_chat.get(to_addr, "nanobot reply") + subject = self._reply_subject(base_subject) + if msg.metadata and isinstance(msg.metadata.get("subject"), str): + override = msg.metadata["subject"].strip() + if override: + subject = override + + email_msg = EmailMessage() + email_msg["From"] = self.config.from_address or self.config.smtp_username or self.config.imap_username + email_msg["To"] = to_addr + email_msg["Subject"] = subject + email_msg.set_content(msg.content or "") + + in_reply_to = self._last_message_id_by_chat.get(to_addr) + if in_reply_to: + email_msg["In-Reply-To"] = in_reply_to + email_msg["References"] = in_reply_to + + try: + await asyncio.to_thread(self._smtp_send, email_msg) + except Exception as e: + logger.error(f"Error sending email to {to_addr}: {e}") + raise + + def _validate_config(self) -> bool: + missing = [] + if not self.config.imap_host: + missing.append("imap_host") + if not self.config.imap_username: + missing.append("imap_username") + if not self.config.imap_password: + missing.append("imap_password") + if not self.config.smtp_host: + missing.append("smtp_host") + if not self.config.smtp_username: + missing.append("smtp_username") + if not self.config.smtp_password: + missing.append("smtp_password") + + if missing: + logger.error(f"Email channel not configured, missing: {', '.join(missing)}") + return False + return True + + def _smtp_send(self, msg: EmailMessage) -> None: + timeout = 30 + if self.config.smtp_use_ssl: + with smtplib.SMTP_SSL( + self.config.smtp_host, + self.config.smtp_port, + timeout=timeout, + ) as smtp: + smtp.login(self.config.smtp_username, self.config.smtp_password) + smtp.send_message(msg) + return + + with smtplib.SMTP(self.config.smtp_host, self.config.smtp_port, timeout=timeout) as smtp: + if self.config.smtp_use_tls: + smtp.starttls(context=ssl.create_default_context()) + smtp.login(self.config.smtp_username, self.config.smtp_password) + smtp.send_message(msg) + + def _fetch_new_messages(self) -> list[dict[str, Any]]: + """Poll IMAP and return parsed unread messages.""" + return self._fetch_messages( + search_criteria=("UNSEEN",), + mark_seen=self.config.mark_seen, + dedupe=True, + limit=0, + ) + + def fetch_messages_between_dates( + self, + start_date: date, + end_date: date, + limit: int = 20, + ) -> list[dict[str, Any]]: + """ + Fetch messages in [start_date, end_date) by IMAP date search. + + This is used for historical summarization tasks (e.g. "yesterday"). + """ + if end_date <= start_date: + return [] + + return self._fetch_messages( + search_criteria=( + "SINCE", + self._format_imap_date(start_date), + "BEFORE", + self._format_imap_date(end_date), + ), + mark_seen=False, + dedupe=False, + limit=max(1, int(limit)), + ) + + def _fetch_messages( + self, + search_criteria: tuple[str, ...], + mark_seen: bool, + dedupe: bool, + limit: int, + ) -> list[dict[str, Any]]: + """Fetch messages by arbitrary IMAP search criteria.""" + messages: list[dict[str, Any]] = [] + mailbox = self.config.imap_mailbox or "INBOX" + + if self.config.imap_use_ssl: + client = imaplib.IMAP4_SSL(self.config.imap_host, self.config.imap_port) + else: + client = imaplib.IMAP4(self.config.imap_host, self.config.imap_port) + + try: + client.login(self.config.imap_username, self.config.imap_password) + status, _ = client.select(mailbox) + if status != "OK": + return messages + + status, data = client.search(None, *search_criteria) + if status != "OK" or not data: + return messages + + ids = data[0].split() + if limit > 0 and len(ids) > limit: + ids = ids[-limit:] + for imap_id in ids: + status, fetched = client.fetch(imap_id, "(BODY.PEEK[] UID)") + if status != "OK" or not fetched: + continue + + raw_bytes = self._extract_message_bytes(fetched) + if raw_bytes is None: + continue + + uid = self._extract_uid(fetched) + if dedupe and uid and uid in self._processed_uids: + continue + + parsed = BytesParser(policy=policy.default).parsebytes(raw_bytes) + sender = parseaddr(parsed.get("From", ""))[1].strip().lower() + if not sender: + continue + + subject = self._decode_header_value(parsed.get("Subject", "")) + date_value = parsed.get("Date", "") + message_id = parsed.get("Message-ID", "").strip() + body = self._extract_text_body(parsed) + + if not body: + body = "(empty email body)" + + body = body[: self.config.max_body_chars] + content = ( + f"Email received.\n" + f"From: {sender}\n" + f"Subject: {subject}\n" + f"Date: {date_value}\n\n" + f"{body}" + ) + + metadata = { + "message_id": message_id, + "subject": subject, + "date": date_value, + "sender_email": sender, + "uid": uid, + } + messages.append( + { + "sender": sender, + "subject": subject, + "message_id": message_id, + "content": content, + "metadata": metadata, + } + ) + + if dedupe and uid: + self._processed_uids.add(uid) + + if mark_seen: + client.store(imap_id, "+FLAGS", "\\Seen") + finally: + try: + client.logout() + except Exception: + pass + + return messages + + @classmethod + def _format_imap_date(cls, value: date) -> str: + """Format date for IMAP search (always English month abbreviations).""" + month = cls._IMAP_MONTHS[value.month - 1] + return f"{value.day:02d}-{month}-{value.year}" + + @staticmethod + def _extract_message_bytes(fetched: list[Any]) -> bytes | None: + for item in fetched: + if isinstance(item, tuple) and len(item) >= 2 and isinstance(item[1], (bytes, bytearray)): + return bytes(item[1]) + return None + + @staticmethod + def _extract_uid(fetched: list[Any]) -> str: + for item in fetched: + if isinstance(item, tuple) and item and isinstance(item[0], (bytes, bytearray)): + head = bytes(item[0]).decode("utf-8", errors="ignore") + m = re.search(r"UID\s+(\d+)", head) + if m: + return m.group(1) + return "" + + @staticmethod + def _decode_header_value(value: str) -> str: + if not value: + return "" + try: + return str(make_header(decode_header(value))) + except Exception: + return value + + @classmethod + def _extract_text_body(cls, msg: Any) -> str: + """Best-effort extraction of readable body text.""" + if msg.is_multipart(): + plain_parts: list[str] = [] + html_parts: list[str] = [] + for part in msg.walk(): + if part.get_content_disposition() == "attachment": + continue + content_type = part.get_content_type() + try: + payload = part.get_content() + except Exception: + payload_bytes = part.get_payload(decode=True) or b"" + charset = part.get_content_charset() or "utf-8" + payload = payload_bytes.decode(charset, errors="replace") + if not isinstance(payload, str): + continue + if content_type == "text/plain": + plain_parts.append(payload) + elif content_type == "text/html": + html_parts.append(payload) + if plain_parts: + return "\n\n".join(plain_parts).strip() + if html_parts: + return cls._html_to_text("\n\n".join(html_parts)).strip() + return "" + + try: + payload = msg.get_content() + except Exception: + payload_bytes = msg.get_payload(decode=True) or b"" + charset = msg.get_content_charset() or "utf-8" + payload = payload_bytes.decode(charset, errors="replace") + if not isinstance(payload, str): + return "" + if msg.get_content_type() == "text/html": + return cls._html_to_text(payload).strip() + return payload.strip() + + @staticmethod + def _html_to_text(raw_html: str) -> str: + text = re.sub(r"<\s*br\s*/?>", "\n", raw_html, flags=re.IGNORECASE) + text = re.sub(r"<\s*/\s*p\s*>", "\n", text, flags=re.IGNORECASE) + text = re.sub(r"<[^>]+>", "", text) + return html.unescape(text) + + def _reply_subject(self, base_subject: str) -> str: + subject = (base_subject or "").strip() or "nanobot reply" + prefix = self.config.subject_prefix or "Re: " + if subject.lower().startswith("re:"): + return subject + return f"{prefix}{subject}" diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 64ced48..4a949c8 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -77,6 +77,17 @@ class ChannelManager: logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu 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(f"Email channel not available: {e}") async def start_all(self) -> None: """Start WhatsApp channel and the outbound dispatcher.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9af6ee2..cc512da 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -38,6 +38,36 @@ class DiscordConfig(BaseModel): gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT +class EmailConfig(BaseModel): + """Email channel configuration (IMAP inbound + SMTP outbound).""" + enabled: bool = False + consent_granted: bool = False # Explicit owner permission to access mailbox data + + # IMAP (receive) + imap_host: str = "" + imap_port: int = 993 + imap_username: str = "" + imap_password: str = "" + imap_mailbox: str = "INBOX" + imap_use_ssl: bool = True + + # SMTP (send) + smtp_host: str = "" + smtp_port: int = 587 + smtp_username: str = "" + smtp_password: str = "" + smtp_use_tls: bool = True + smtp_use_ssl: bool = False + from_address: str = "" + + # Behavior + auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + poll_interval_seconds: int = 30 + mark_seen: bool = True + max_body_chars: int = 12000 + subject_prefix: str = "Re: " + allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" @@ -45,6 +75,7 @@ class ChannelsConfig(BaseModel): telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) feishu: FeishuConfig = Field(default_factory=FeishuConfig) + email: EmailConfig = Field(default_factory=EmailConfig) class AgentDefaults(BaseModel): diff --git a/tests/test_email_channel.py b/tests/test_email_channel.py new file mode 100644 index 0000000..8b22d8d --- /dev/null +++ b/tests/test_email_channel.py @@ -0,0 +1,311 @@ +from email.message import EmailMessage +from datetime import date + +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.email import EmailChannel +from nanobot.config.schema import EmailConfig + + +def _make_config() -> EmailConfig: + return EmailConfig( + enabled=True, + consent_granted=True, + imap_host="imap.example.com", + imap_port=993, + imap_username="bot@example.com", + imap_password="secret", + smtp_host="smtp.example.com", + smtp_port=587, + smtp_username="bot@example.com", + smtp_password="secret", + mark_seen=True, + ) + + +def _make_raw_email( + from_addr: str = "alice@example.com", + subject: str = "Hello", + body: str = "This is the body.", +) -> bytes: + msg = EmailMessage() + msg["From"] = from_addr + msg["To"] = "bot@example.com" + msg["Subject"] = subject + msg["Message-ID"] = "" + msg.set_content(body) + return msg.as_bytes() + + +def test_fetch_new_messages_parses_unseen_and_marks_seen(monkeypatch) -> None: + raw = _make_raw_email(subject="Invoice", body="Please pay") + + class FakeIMAP: + def __init__(self) -> None: + self.store_calls: list[tuple[bytes, str, str]] = [] + + def login(self, _user: str, _pw: str): + return "OK", [b"logged in"] + + def select(self, _mailbox: str): + return "OK", [b"1"] + + def search(self, *_args): + return "OK", [b"1"] + + def fetch(self, _imap_id: bytes, _parts: str): + return "OK", [(b"1 (UID 123 BODY[] {200})", raw), b")"] + + def store(self, imap_id: bytes, op: str, flags: str): + self.store_calls.append((imap_id, op, flags)) + return "OK", [b""] + + def logout(self): + return "BYE", [b""] + + fake = FakeIMAP() + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + channel = EmailChannel(_make_config(), MessageBus()) + items = channel._fetch_new_messages() + + assert len(items) == 1 + assert items[0]["sender"] == "alice@example.com" + assert items[0]["subject"] == "Invoice" + assert "Please pay" in items[0]["content"] + assert fake.store_calls == [(b"1", "+FLAGS", "\\Seen")] + + # Same UID should be deduped in-process. + items_again = channel._fetch_new_messages() + assert items_again == [] + + +def test_extract_text_body_falls_back_to_html() -> None: + msg = EmailMessage() + msg["From"] = "alice@example.com" + msg["To"] = "bot@example.com" + msg["Subject"] = "HTML only" + msg.add_alternative("

Hello
world

", subtype="html") + + text = EmailChannel._extract_text_body(msg) + assert "Hello" in text + assert "world" in text + + +@pytest.mark.asyncio +async def test_start_returns_immediately_without_consent(monkeypatch) -> None: + cfg = _make_config() + cfg.consent_granted = False + channel = EmailChannel(cfg, MessageBus()) + + called = {"fetch": False} + + def _fake_fetch(): + called["fetch"] = True + return [] + + monkeypatch.setattr(channel, "_fetch_new_messages", _fake_fetch) + await channel.start() + assert channel.is_running is False + assert called["fetch"] is False + + +@pytest.mark.asyncio +async def test_send_uses_smtp_and_reply_subject(monkeypatch) -> None: + class FakeSMTP: + def __init__(self, _host: str, _port: int, timeout: int = 30) -> None: + self.timeout = timeout + self.started_tls = False + self.logged_in = False + self.sent_messages: list[EmailMessage] = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def starttls(self, context=None): + self.started_tls = True + + def login(self, _user: str, _pw: str): + self.logged_in = True + + def send_message(self, msg: EmailMessage): + self.sent_messages.append(msg) + + fake_instances: list[FakeSMTP] = [] + + def _smtp_factory(host: str, port: int, timeout: int = 30): + instance = FakeSMTP(host, port, timeout=timeout) + fake_instances.append(instance) + return instance + + monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory) + + channel = EmailChannel(_make_config(), MessageBus()) + channel._last_subject_by_chat["alice@example.com"] = "Invoice #42" + channel._last_message_id_by_chat["alice@example.com"] = "" + + await channel.send( + OutboundMessage( + channel="email", + chat_id="alice@example.com", + content="Acknowledged.", + ) + ) + + assert len(fake_instances) == 1 + smtp = fake_instances[0] + assert smtp.started_tls is True + assert smtp.logged_in is True + assert len(smtp.sent_messages) == 1 + sent = smtp.sent_messages[0] + assert sent["Subject"] == "Re: Invoice #42" + assert sent["To"] == "alice@example.com" + assert sent["In-Reply-To"] == "" + + +@pytest.mark.asyncio +async def test_send_skips_when_auto_reply_disabled(monkeypatch) -> None: + class FakeSMTP: + def __init__(self, _host: str, _port: int, timeout: int = 30) -> None: + self.sent_messages: list[EmailMessage] = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def starttls(self, context=None): + return None + + def login(self, _user: str, _pw: str): + return None + + def send_message(self, msg: EmailMessage): + self.sent_messages.append(msg) + + fake_instances: list[FakeSMTP] = [] + + def _smtp_factory(host: str, port: int, timeout: int = 30): + instance = FakeSMTP(host, port, timeout=timeout) + fake_instances.append(instance) + return instance + + monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory) + + cfg = _make_config() + cfg.auto_reply_enabled = False + channel = EmailChannel(cfg, MessageBus()) + await channel.send( + OutboundMessage( + channel="email", + chat_id="alice@example.com", + content="Should not send.", + ) + ) + assert fake_instances == [] + + await channel.send( + OutboundMessage( + channel="email", + chat_id="alice@example.com", + content="Force send.", + metadata={"force_send": True}, + ) + ) + assert len(fake_instances) == 1 + assert len(fake_instances[0].sent_messages) == 1 + + +@pytest.mark.asyncio +async def test_send_skips_when_consent_not_granted(monkeypatch) -> None: + class FakeSMTP: + def __init__(self, _host: str, _port: int, timeout: int = 30) -> None: + self.sent_messages: list[EmailMessage] = [] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def starttls(self, context=None): + return None + + def login(self, _user: str, _pw: str): + return None + + def send_message(self, msg: EmailMessage): + self.sent_messages.append(msg) + + called = {"smtp": False} + + def _smtp_factory(host: str, port: int, timeout: int = 30): + called["smtp"] = True + return FakeSMTP(host, port, timeout=timeout) + + monkeypatch.setattr("nanobot.channels.email.smtplib.SMTP", _smtp_factory) + + cfg = _make_config() + cfg.consent_granted = False + channel = EmailChannel(cfg, MessageBus()) + await channel.send( + OutboundMessage( + channel="email", + chat_id="alice@example.com", + content="Should not send.", + metadata={"force_send": True}, + ) + ) + assert called["smtp"] is False + + +def test_fetch_messages_between_dates_uses_imap_since_before_without_mark_seen(monkeypatch) -> None: + raw = _make_raw_email(subject="Status", body="Yesterday update") + + class FakeIMAP: + def __init__(self) -> None: + self.search_args = None + self.store_calls: list[tuple[bytes, str, str]] = [] + + def login(self, _user: str, _pw: str): + return "OK", [b"logged in"] + + def select(self, _mailbox: str): + return "OK", [b"1"] + + def search(self, *_args): + self.search_args = _args + return "OK", [b"5"] + + def fetch(self, _imap_id: bytes, _parts: str): + return "OK", [(b"5 (UID 999 BODY[] {200})", raw), b")"] + + def store(self, imap_id: bytes, op: str, flags: str): + self.store_calls.append((imap_id, op, flags)) + return "OK", [b""] + + def logout(self): + return "BYE", [b""] + + fake = FakeIMAP() + monkeypatch.setattr("nanobot.channels.email.imaplib.IMAP4_SSL", lambda _h, _p: fake) + + channel = EmailChannel(_make_config(), MessageBus()) + items = channel.fetch_messages_between_dates( + start_date=date(2026, 2, 6), + end_date=date(2026, 2, 7), + limit=10, + ) + + assert len(items) == 1 + assert items[0]["subject"] == "Status" + # search(None, "SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026") + assert fake.search_args is not None + assert fake.search_args[1:] == ("SINCE", "06-Feb-2026", "BEFORE", "07-Feb-2026") + assert fake.store_calls == [] From b179a028c36876efa11f9f2d4dd22d55953ddf32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20H=C3=B6hne?= Date: Sat, 7 Feb 2026 11:44:20 +0000 Subject: [PATCH 04/58] Fixes Access Denied because only the LID was used. --- bridge/src/whatsapp.ts | 2 ++ nanobot/channels/whatsapp.py | 14 +++++++++----- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index a3a82fc..069d72b 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -20,6 +20,7 @@ const VERSION = '0.1.0'; export interface InboundMessage { id: string; sender: string; + pn: string; content: string; timestamp: number; isGroup: boolean; @@ -123,6 +124,7 @@ export class WhatsAppClient { this.options.onMessage({ id: msg.key.id || '', sender: msg.key.remoteJid || '', + pn: msg.key.remoteJidAlt || '', content, timestamp: msg.messageTimestamp as number, isGroup, diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index c14a6c3..1974017 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -100,12 +100,16 @@ class WhatsAppChannel(BaseChannel): if msg_type == "message": # Incoming message from WhatsApp + # Deprecated by whatsapp: old phone number style typically: @s.whatspp.net + pn = data.get("pn", "") + # New LID sytle typically: sender = data.get("sender", "") content = data.get("content", "") - # sender is typically: @s.whatsapp.net - # Extract just the phone number as chat_id - chat_id = sender.split("@")[0] if "@" in sender else sender + # Extract just the phone number or lid as chat_id + user_id = pn if pn else sender + sender_id = user_id.split("@")[0] if "@" in sender else sender + logger.info(f"Sender {sender}") # Handle voice transcription if it's a voice message if content == "[Voice Message]": @@ -113,8 +117,8 @@ class WhatsAppChannel(BaseChannel): content = "[Voice Message: Transcription not available for WhatsApp yet]" await self._handle_message( - sender_id=chat_id, - chat_id=sender, # Use full JID for replies + sender_id=sender_id, + chat_id=sender, # Use full LID for replies content=content, metadata={ "message_id": data.get("id"), From 3166c15cffa4217e24be4c55fa25f9436370801c Mon Sep 17 00:00:00 2001 From: alan Date: Sat, 7 Feb 2026 20:37:41 +0800 Subject: [PATCH 05/58] feat: add telegram proxy support and add error handling for channel startup --- nanobot/channels/manager.py | 13 ++++++++++--- nanobot/channels/telegram.py | 2 ++ pyproject.toml | 5 +++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 64ced48..846ea70 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -78,8 +78,15 @@ class ChannelManager: except ImportError as e: logger.warning(f"Feishu channel not available: {e}") + async def _start_channel(self, name: str, channel: BaseChannel) -> None: + """Start a channel and log any exceptions.""" + try: + await channel.start() + except Exception as e: + logger.error(f"Failed to start channel {name}: {e}") + async def start_all(self) -> None: - """Start WhatsApp channel and the outbound dispatcher.""" + """Start all channels and the outbound dispatcher.""" if not self.channels: logger.warning("No channels enabled") return @@ -87,11 +94,11 @@ class ChannelManager: # Start outbound dispatcher self._dispatch_task = asyncio.create_task(self._dispatch_outbound()) - # Start WhatsApp channel + # Start channels tasks = [] for name, channel in self.channels.items(): logger.info(f"Starting {name} channel...") - tasks.append(asyncio.create_task(channel.start())) + tasks.append(asyncio.create_task(self._start_channel(name, channel))) # Wait for all to complete (they should run forever) await asyncio.gather(*tasks, return_exceptions=True) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 23e1de0..b62c63b 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -104,6 +104,8 @@ class TelegramChannel(BaseChannel): self._app = ( Application.builder() .token(self.config.token) + .proxy(self.config.proxy) + .get_updates_proxy(self.config.proxy) .build() ) diff --git a/pyproject.toml b/pyproject.toml index 2a952a1..f60f7a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,13 +23,14 @@ dependencies = [ "pydantic-settings>=2.0.0", "websockets>=12.0", "websocket-client>=1.6.0", - "httpx>=0.25.0", + "httpx[socks]>=0.25.0", "loguru>=0.7.0", "readability-lxml>=0.8.0", "rich>=13.0.0", "croniter>=2.0.0", - "python-telegram-bot>=21.0", + "python-telegram-bot[socks]>=21.0", "lark-oapi>=1.0.0", + "socksio>=1.0.0", ] [project.optional-dependencies] From cf1663af13310f993ee835d857bf78cbbc3b7a05 Mon Sep 17 00:00:00 2001 From: alan Date: Sat, 7 Feb 2026 22:18:43 +0800 Subject: [PATCH 06/58] feat: conditionally set telegram proxy --- nanobot/channels/telegram.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index b62c63b..f2b6d1f 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -101,13 +101,10 @@ class TelegramChannel(BaseChannel): self._running = True # Build the application - self._app = ( - Application.builder() - .token(self.config.token) - .proxy(self.config.proxy) - .get_updates_proxy(self.config.proxy) - .build() - ) + builder = Application.builder().token(self.config.token) + if self.config.proxy: + builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) + self._app = builder.build() # Add message handler for text, photos, voice, documents self._app.add_handler( From 544eefbc8afc27bc5c05d1cdae6f2ccd9d81c220 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Feb 2026 17:40:46 +0000 Subject: [PATCH 07/58] fix: correct variable references in WhatsApp LID handling --- nanobot/channels/whatsapp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 1974017..6e00e9d 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -108,12 +108,12 @@ class WhatsAppChannel(BaseChannel): # Extract just the phone number or lid as chat_id user_id = pn if pn else sender - sender_id = user_id.split("@")[0] if "@" in sender else sender + sender_id = user_id.split("@")[0] if "@" in user_id else user_id logger.info(f"Sender {sender}") # Handle voice transcription if it's a voice message if content == "[Voice Message]": - logger.info(f"Voice message received from {chat_id}, but direct download from bridge is not yet supported.") + logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.") content = "[Voice Message: Transcription not available for WhatsApp yet]" await self._handle_message( From 9fe2c09fd3bb0041f689ff195e0812965354807b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Feb 2026 18:01:14 +0000 Subject: [PATCH 08/58] bump version to 0.1.3.post5 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f60f7a7..4093474 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post4" +version = "0.1.3.post5" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 438ec66fd8134148308db7bcffd45b2e830157cf Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Feb 2026 18:15:18 +0000 Subject: [PATCH 09/58] docs: v0.1.3.post5 release news --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a15892..a1ea905 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@ ## ๐Ÿ“ข News +- **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** โœจ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! -- **2026-02-04** ๐Ÿš€ Released v0.1.3.post4 with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. +- **2026-02-04** ๐Ÿš€ Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. - **2026-02-03** โšก Integrated vLLM for local LLM support and improved natural language task scheduling! - **2026-02-02** ๐ŸŽ‰ nanobot officially launched! Welcome to try ๐Ÿˆ nanobot! From 8b1ef77970a4b8634dadfc1560a20adba3934c01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?= Date: Sun, 8 Feb 2026 10:38:32 +0800 Subject: [PATCH 10/58] fix(cli): keep prompt stable and flush stale arrow-key input --- nanobot/cli/commands.py | 40 ++++++++++++++++++++++++++++++++- tests/test_cli_input_minimal.py | 37 ++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli_input_minimal.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 19e62e9..e70fd32 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,7 +1,10 @@ """CLI commands for nanobot.""" import asyncio +import os from pathlib import Path +import select +import sys import typer from rich.console import Console @@ -18,6 +21,40 @@ app = typer.Typer( console = Console() +def _flush_pending_tty_input() -> None: + """Drop unread keypresses typed while the model was generating output.""" + try: + fd = sys.stdin.fileno() + if not os.isatty(fd): + return + except Exception: + return + + try: + import termios + + termios.tcflush(fd, termios.TCIFLUSH) + return + except Exception: + pass + + try: + while True: + ready, _, _ = select.select([fd], [], [], 0) + if not ready: + break + if not os.read(fd, 4096): + break + except Exception: + return + + +def _read_interactive_input() -> str: + """Read user input with a stable prompt for terminal line editing.""" + console.print("[bold blue]You:[/bold blue] ", end="") + return input() + + def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") @@ -318,7 +355,8 @@ def agent( async def run_interactive(): while True: try: - user_input = console.input("[bold blue]You:[/bold blue] ") + _flush_pending_tty_input() + user_input = _read_interactive_input() if not user_input.strip(): continue diff --git a/tests/test_cli_input_minimal.py b/tests/test_cli_input_minimal.py new file mode 100644 index 0000000..49d9d4f --- /dev/null +++ b/tests/test_cli_input_minimal.py @@ -0,0 +1,37 @@ +import builtins + +import nanobot.cli.commands as commands + + +def test_read_interactive_input_uses_plain_input(monkeypatch) -> None: + captured: dict[str, object] = {} + + def fake_print(*args, **kwargs): + captured["printed"] = args + captured["print_kwargs"] = kwargs + + def fake_input(prompt: str = "") -> str: + captured["prompt"] = prompt + return "hello" + + monkeypatch.setattr(commands.console, "print", fake_print) + monkeypatch.setattr(builtins, "input", fake_input) + + value = commands._read_interactive_input() + + assert value == "hello" + assert captured["prompt"] == "" + assert captured["print_kwargs"] == {"end": ""} + assert captured["printed"] == ("[bold blue]You:[/bold blue] ",) + + +def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None: + class FakeStdin: + def fileno(self) -> int: + return 0 + + monkeypatch.setattr(commands.sys, "stdin", FakeStdin()) + monkeypatch.setattr(commands.os, "isatty", lambda _fd: False) + + commands._flush_pending_tty_input() + From 342ba2b87976cb9c282e8d6760fbfa8133509703 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=B6=94=E7=86=99?= Date: Sun, 8 Feb 2026 11:10:03 +0800 Subject: [PATCH 11/58] fix(cli): stabilize wrapped CJK arrow navigation in interactive input --- nanobot/cli/commands.py | 255 +++++++++++++++++++++++++++++++- pyproject.toml | 1 + tests/test_cli_input_minimal.py | 41 +++-- 3 files changed, 282 insertions(+), 15 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index e70fd32..bd7a408 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,10 +1,12 @@ """CLI commands for nanobot.""" import asyncio +import atexit import os from pathlib import Path import select import sys +from typing import Any import typer from rich.console import Console @@ -19,6 +21,12 @@ app = typer.Typer( ) console = Console() +_READLINE: Any | None = None +_HISTORY_FILE: Path | None = None +_HISTORY_HOOK_REGISTERED = False +_USING_LIBEDIT = False +_PROMPT_SESSION: Any | None = None +_PROMPT_SESSION_LABEL: Any = None def _flush_pending_tty_input() -> None: @@ -49,10 +57,248 @@ def _flush_pending_tty_input() -> None: return +def _save_history() -> None: + if _READLINE is None or _HISTORY_FILE is None: + return + try: + _READLINE.write_history_file(str(_HISTORY_FILE)) + except Exception: + return + + +def _enable_line_editing() -> None: + """Best-effort enable readline/libedit line editing for arrow keys/history.""" + global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT + global _PROMPT_SESSION, _PROMPT_SESSION_LABEL + + history_file = Path.home() / ".nanobot" / "history" / "cli_history" + history_file.parent.mkdir(parents=True, exist_ok=True) + _HISTORY_FILE = history_file + + # Preferred path: prompt_toolkit handles wrapped wide-char rendering better. + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.formatted_text import ANSI + from prompt_toolkit.history import FileHistory + from prompt_toolkit.key_binding import KeyBindings + + key_bindings = KeyBindings() + + @key_bindings.add("enter") + def _accept_input(event) -> None: + _clear_visual_nav_state(event.current_buffer) + event.current_buffer.validate_and_handle() + + @key_bindings.add("up") + def _handle_up(event) -> None: + count = event.arg if event.arg and event.arg > 0 else 1 + moved = _move_buffer_cursor_visual_from_render( + buffer=event.current_buffer, + event=event, + delta=-1, + count=count, + ) + if not moved: + event.current_buffer.history_backward(count=count) + _clear_visual_nav_state(event.current_buffer) + + @key_bindings.add("down") + def _handle_down(event) -> None: + count = event.arg if event.arg and event.arg > 0 else 1 + moved = _move_buffer_cursor_visual_from_render( + buffer=event.current_buffer, + event=event, + delta=1, + count=count, + ) + if not moved: + event.current_buffer.history_forward(count=count) + _clear_visual_nav_state(event.current_buffer) + + _PROMPT_SESSION = PromptSession( + history=FileHistory(str(history_file)), + multiline=True, + wrap_lines=True, + complete_while_typing=False, + key_bindings=key_bindings, + ) + _PROMPT_SESSION.default_buffer.on_text_changed += ( + lambda _event: _clear_visual_nav_state(_PROMPT_SESSION.default_buffer) + ) + _PROMPT_SESSION_LABEL = ANSI("\x1b[1;34mYou:\x1b[0m ") + _READLINE = None + _USING_LIBEDIT = False + return + except Exception: + _PROMPT_SESSION = None + _PROMPT_SESSION_LABEL = None + + try: + import readline + except Exception: + return + + _READLINE = readline + _USING_LIBEDIT = "libedit" in (readline.__doc__ or "").lower() + + try: + if _USING_LIBEDIT: + readline.parse_and_bind("bind ^I rl_complete") + else: + readline.parse_and_bind("tab: complete") + readline.parse_and_bind("set editing-mode emacs") + except Exception: + pass + + try: + readline.read_history_file(str(history_file)) + except Exception: + pass + + if not _HISTORY_HOOK_REGISTERED: + atexit.register(_save_history) + _HISTORY_HOOK_REGISTERED = True + + +def _prompt_text() -> str: + """Build a readline-friendly colored prompt.""" + if _READLINE is None: + return "You: " + # libedit on macOS does not honor GNU readline non-printing markers. + if _USING_LIBEDIT: + return "\033[1;34mYou:\033[0m " + return "\001\033[1;34m\002You:\001\033[0m\002 " + + def _read_interactive_input() -> str: - """Read user input with a stable prompt for terminal line editing.""" - console.print("[bold blue]You:[/bold blue] ", end="") - return input() + """Read user input with stable prompt rendering (sync fallback).""" + return input(_prompt_text()) + + +async def _read_interactive_input_async() -> str: + """Read user input safely inside the interactive asyncio loop.""" + if _PROMPT_SESSION is not None: + try: + return await _PROMPT_SESSION.prompt_async(_PROMPT_SESSION_LABEL) + except EOFError as exc: + raise KeyboardInterrupt from exc + try: + return await asyncio.to_thread(_read_interactive_input) + except EOFError as exc: + raise KeyboardInterrupt from exc + + +def _choose_visual_rowcol( + rowcol_to_yx: dict[tuple[int, int], tuple[int, int]], + current_rowcol: tuple[int, int], + delta: int, + preferred_x: int | None = None, +) -> tuple[tuple[int, int] | None, int | None]: + """Choose next logical row/col by rendered screen coordinates.""" + if delta not in (-1, 1): + return None, preferred_x + + current_yx = rowcol_to_yx.get(current_rowcol) + if current_yx is None: + same_row = [ + (rowcol, yx) + for rowcol, yx in rowcol_to_yx.items() + if rowcol[0] == current_rowcol[0] + ] + if not same_row: + return None, preferred_x + _, current_yx = min(same_row, key=lambda item: abs(item[0][1] - current_rowcol[1])) + + target_x = current_yx[1] if preferred_x is None else preferred_x + target_y = current_yx[0] + delta + candidates = [(rowcol, yx) for rowcol, yx in rowcol_to_yx.items() if yx[0] == target_y] + if not candidates: + return None, preferred_x + + best_rowcol, _ = min( + candidates, + key=lambda item: (abs(item[1][1] - target_x), item[1][1] < target_x, item[1][1]), + ) + return best_rowcol, target_x + + +def _clear_visual_nav_state(buffer: Any) -> None: + """Reset cached vertical-navigation anchor state.""" + setattr(buffer, "_nanobot_visual_pref_x", None) + setattr(buffer, "_nanobot_visual_last_dir", None) + setattr(buffer, "_nanobot_visual_last_cursor", None) + setattr(buffer, "_nanobot_visual_last_text", None) + + +def _can_reuse_visual_anchor(buffer: Any, delta: int) -> bool: + """Reuse anchor only for uninterrupted vertical navigation.""" + return ( + getattr(buffer, "_nanobot_visual_last_dir", None) == delta + and getattr(buffer, "_nanobot_visual_last_cursor", None) == buffer.cursor_position + and getattr(buffer, "_nanobot_visual_last_text", None) == buffer.text + ) + + +def _remember_visual_anchor(buffer: Any, delta: int) -> None: + """Remember current state as anchor baseline for repeated up/down.""" + setattr(buffer, "_nanobot_visual_last_dir", delta) + setattr(buffer, "_nanobot_visual_last_cursor", buffer.cursor_position) + setattr(buffer, "_nanobot_visual_last_text", buffer.text) + + +def _move_buffer_cursor_visual_from_render( + buffer: Any, + event: Any, + delta: int, + count: int, +) -> bool: + """Move cursor across rendered screen rows (soft-wrap/CJK aware).""" + try: + window = event.app.layout.current_window + render_info = getattr(window, "render_info", None) + rowcol_to_yx = getattr(render_info, "_rowcol_to_yx", None) + if not isinstance(rowcol_to_yx, dict) or not rowcol_to_yx: + return False + except Exception: + return False + + moved_any = False + preferred_x = ( + getattr(buffer, "_nanobot_visual_pref_x", None) + if _can_reuse_visual_anchor(buffer, delta) + else None + ) + steps = max(1, count) + + for _ in range(steps): + doc = buffer.document + current_rowcol = (doc.cursor_position_row, doc.cursor_position_col) + next_rowcol, preferred_x = _choose_visual_rowcol( + rowcol_to_yx=rowcol_to_yx, + current_rowcol=current_rowcol, + delta=delta, + preferred_x=preferred_x, + ) + if next_rowcol is None: + break + + try: + new_position = doc.translate_row_col_to_index(*next_rowcol) + except Exception: + break + if new_position == buffer.cursor_position: + break + + buffer.cursor_position = new_position + moved_any = True + + if moved_any: + setattr(buffer, "_nanobot_visual_pref_x", preferred_x) + _remember_visual_anchor(buffer, delta) + else: + _clear_visual_nav_state(buffer) + + return moved_any def version_callback(value: bool): @@ -350,13 +596,14 @@ def agent( asyncio.run(run_once()) else: # Interactive mode + _enable_line_editing() console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n") async def run_interactive(): while True: try: _flush_pending_tty_input() - user_input = _read_interactive_input() + user_input = await _read_interactive_input_async() if not user_input.strip(): continue diff --git a/pyproject.toml b/pyproject.toml index 4093474..b1bc3de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "python-telegram-bot[socks]>=21.0", "lark-oapi>=1.0.0", "socksio>=1.0.0", + "prompt-toolkit>=3.0.47", ] [project.optional-dependencies] diff --git a/tests/test_cli_input_minimal.py b/tests/test_cli_input_minimal.py index 49d9d4f..4726ea3 100644 --- a/tests/test_cli_input_minimal.py +++ b/tests/test_cli_input_minimal.py @@ -4,25 +4,45 @@ import nanobot.cli.commands as commands def test_read_interactive_input_uses_plain_input(monkeypatch) -> None: - captured: dict[str, object] = {} - - def fake_print(*args, **kwargs): - captured["printed"] = args - captured["print_kwargs"] = kwargs - + captured: dict[str, str] = {} def fake_input(prompt: str = "") -> str: captured["prompt"] = prompt return "hello" - monkeypatch.setattr(commands.console, "print", fake_print) monkeypatch.setattr(builtins, "input", fake_input) + monkeypatch.setattr(commands, "_PROMPT_SESSION", None) + monkeypatch.setattr(commands, "_READLINE", None) value = commands._read_interactive_input() assert value == "hello" - assert captured["prompt"] == "" - assert captured["print_kwargs"] == {"end": ""} - assert captured["printed"] == ("[bold blue]You:[/bold blue] ",) + assert captured["prompt"] == "You: " + + +def test_read_interactive_input_prefers_prompt_session(monkeypatch) -> None: + captured: dict[str, object] = {} + + class FakePromptSession: + async def prompt_async(self, label: object) -> str: + captured["label"] = label + return "hello" + + monkeypatch.setattr(commands, "_PROMPT_SESSION", FakePromptSession()) + monkeypatch.setattr(commands, "_PROMPT_SESSION_LABEL", "LBL") + + value = __import__("asyncio").run(commands._read_interactive_input_async()) + + assert value == "hello" + assert captured["label"] == "LBL" + + +def test_prompt_text_for_readline_modes(monkeypatch) -> None: + monkeypatch.setattr(commands, "_READLINE", object()) + monkeypatch.setattr(commands, "_USING_LIBEDIT", True) + assert commands._prompt_text() == "\033[1;34mYou:\033[0m " + + monkeypatch.setattr(commands, "_USING_LIBEDIT", False) + assert "\001" in commands._prompt_text() def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None: @@ -34,4 +54,3 @@ def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None: monkeypatch.setattr(commands.os, "isatty", lambda _fd: False) commands._flush_pending_tty_input() - From 240db894b43ddf521c83850be57d8025cbc27562 Mon Sep 17 00:00:00 2001 From: w0x7ce Date: Sun, 8 Feb 2026 11:37:36 +0800 Subject: [PATCH 12/58] feat(channels): add DingTalk channel support and documentation --- README.md | 43 +++++++ nanobot/channels/dingtalk.py | 219 +++++++++++++++++++++++++++++++++++ nanobot/channels/manager.py | 11 ++ nanobot/config/schema.py | 9 ++ pyproject.toml | 1 + 5 files changed, 283 insertions(+) create mode 100644 nanobot/channels/dingtalk.py diff --git a/README.md b/README.md index a1ea905..95a5625 100644 --- a/README.md +++ b/README.md @@ -336,6 +336,49 @@ nanobot gateway +
+DingTalk (้’‰้’‰) + +Uses **Stream Mode** โ€” no public IP required. + +```bash +pip install nanobot-ai[dingtalk] +``` + +**1. Create a DingTalk bot** +- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/) +- Create a new app -> Add **Robot** capability +- **Configuration**: + - Toggle **Stream Mode** ON +- **Permissions**: Add necessary permissions for sending messages +- Get **AppKey** (Client ID) and **AppSecret** (Client Secret) from "Credentials" +- Publish the app + +**2. Configure** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "clientId": "YOUR_APP_KEY", + "clientSecret": "YOUR_APP_SECRET", + "allowFrom": [] + } + } +} +``` + +> `allowFrom`: Leave empty to allow all users, or add `["staffId"]` to restrict access. + +**3. Run** + +```bash +nanobot gateway +``` + +
+ ## โš™๏ธ Configuration Config file: `~/.nanobot/config.json` diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py new file mode 100644 index 0000000..897e5be --- /dev/null +++ b/nanobot/channels/dingtalk.py @@ -0,0 +1,219 @@ +"""DingTalk/DingDing channel implementation using Stream Mode.""" + +import asyncio +import json +import threading +import time +from typing import Any + +from loguru import logger +import httpx + +from nanobot.bus.events import OutboundMessage, InboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import DingTalkConfig + +try: + from dingtalk_stream import ( + DingTalkStreamClient, + Credential, + CallbackHandler, + CallbackMessage, + AckMessage + ) + from dingtalk_stream.chatbot import ChatbotMessage + DINGTALK_AVAILABLE = True +except ImportError: + DINGTALK_AVAILABLE = False + + +class NanobotDingTalkHandler(CallbackHandler): + """ + Standard DingTalk Stream SDK Callback Handler. + Parses incoming messages and forwards them to the Nanobot channel. + """ + def __init__(self, channel: "DingTalkChannel"): + super().__init__() + self.channel = channel + + async def process(self, message: CallbackMessage): + """Process incoming stream message.""" + try: + # Parse using SDK's ChatbotMessage for robust handling + chatbot_msg = ChatbotMessage.from_dict(message.data) + + # Extract content based on message type + content = "" + if chatbot_msg.text: + content = chatbot_msg.text.content.strip() + elif chatbot_msg.message_type == "text": + # Fallback manual extraction if object not populated + content = message.data.get("text", {}).get("content", "").strip() + + if not content: + logger.warning(f"Received empty or unsupported message type: {chatbot_msg.message_type}") + return AckMessage.STATUS_OK, "OK" + + sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id + sender_name = chatbot_msg.sender_nick or "Unknown" + + logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}") + + # Forward to Nanobot + # We use asyncio.create_task to avoid blocking the ACK return + asyncio.create_task( + self.channel._on_message(content, sender_id, sender_name) + ) + + return AckMessage.STATUS_OK, "OK" + + except Exception as e: + logger.error(f"Error processing DingTalk message: {e}") + # Return OK to avoid retry loop from DingTalk server if it's a parsing error + return AckMessage.STATUS_OK, "Error" + +class DingTalkChannel(BaseChannel): + """ + DingTalk channel using Stream Mode. + + Uses WebSocket to receive events via `dingtalk-stream` SDK. + Uses direct HTTP API to send messages (since SDK is mainly for receiving). + """ + + name = "dingtalk" + + def __init__(self, config: DingTalkConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: DingTalkConfig = config + self._client: Any = None + self._loop: asyncio.AbstractEventLoop | None = None + + # Access Token management for sending messages + self._access_token: str | None = None + self._token_expiry: float = 0 + + async def start(self) -> None: + """Start the DingTalk bot with Stream Mode.""" + try: + if not DINGTALK_AVAILABLE: + logger.error("DingTalk Stream SDK not installed. Run: pip install dingtalk-stream") + return + + if not self.config.client_id or not self.config.client_secret: + logger.error("DingTalk client_id and client_secret not configured") + return + + self._running = True + self._loop = asyncio.get_running_loop() + + logger.info(f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}...") + credential = Credential(self.config.client_id, self.config.client_secret) + self._client = DingTalkStreamClient(credential) + + # Register standard handler + handler = NanobotDingTalkHandler(self) + + # Register using the chatbot topic standard for bots + self._client.register_callback_handler( + ChatbotMessage.TOPIC, + handler + ) + + logger.info("DingTalk bot started with Stream Mode") + + # The client.start() method is an async infinite loop that handles the websocket connection + await self._client.start() + + except Exception as e: + logger.exception(f"Failed to start DingTalk channel: {e}") + + async def stop(self) -> None: + """Stop the DingTalk bot.""" + self._running = False + # SDK doesn't expose a clean stop method that cancels loop immediately without private access + pass + + async def _get_access_token(self) -> str | None: + """Get or refresh Access Token.""" + if self._access_token and time.time() < self._token_expiry: + return self._access_token + + url = "https://api.dingtalk.com/v1.0/oauth2/accessToken" + data = { + "appKey": self.config.client_id, + "appSecret": self.config.client_secret + } + + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=data) + resp.raise_for_status() + res_data = resp.json() + self._access_token = res_data.get("accessToken") + # Expire 60s early to be safe + self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 + return self._access_token + except Exception as e: + logger.error(f"Failed to get DingTalk access token: {e}") + return None + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through DingTalk.""" + token = await self._get_access_token() + if not token: + return + + # This endpoint is for sending to a single user in a bot chat + # https://open.dingtalk.com/document/orgapp/robot-batch-send-messages + url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + + headers = { + "x-acs-dingtalk-access-token": token + } + + # Convert markdown code blocks for basic compatibility if needed, + # but DingTalk supports markdown loosely. + + data = { + "robotCode": self.config.client_id, + "userIds": [msg.chat_id], # chat_id is the user's staffId/unionId + "msgKey": "sampleMarkdown", # Using markdown template + "msgParam": json.dumps({ + "text": msg.content, + "title": "Nanobot Reply" + }) + } + + try: + async with httpx.AsyncClient() as client: + resp = await client.post(url, json=data, headers=headers) + # Check 200 OK but also API error codes if any + if resp.status_code != 200: + logger.error(f"DingTalk send failed: {resp.text}") + else: + logger.debug(f"DingTalk message sent to {msg.chat_id}") + except Exception as e: + logger.error(f"Error sending DingTalk message: {e}") + + async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: + """Handle incoming message (called by NanobotDingTalkHandler).""" + try: + logger.info(f"DingTalk inbound: {content} from {sender_name}") + + # Correct InboundMessage usage based on events.py definition + # @dataclass class InboundMessage: + # channel: str, sender_id: str, chat_id: str, content: str, ... + msg = InboundMessage( + channel=self.name, + sender_id=sender_id, + chat_id=sender_id, # For private stats, chat_id is sender_id + content=str(content), + metadata={ + "sender_name": sender_name, + "platform": "dingtalk" + } + ) + await self.bus.publish_inbound(msg) + except Exception as e: + logger.error(f"Error publishing DingTalk message: {e}") diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 846ea70..c7ab7c3 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -77,6 +77,17 @@ class ChannelManager: logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu 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(f"DingTalk channel not available: {e}") async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7724288..e46b5df 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -30,6 +30,14 @@ class FeishuConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids +class DingTalkConfig(BaseModel): + """DingTalk channel configuration using Stream mode.""" + enabled: bool = False + client_id: str = "" # AppKey + client_secret: str = "" # AppSecret + allow_from: list[str] = Field(default_factory=list) # Allowed staff_ids + + class DiscordConfig(BaseModel): """Discord channel configuration.""" enabled: bool = False @@ -45,6 +53,7 @@ class ChannelsConfig(BaseModel): telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) feishu: FeishuConfig = Field(default_factory=FeishuConfig) + dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 4093474..6fda084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ "readability-lxml>=0.8.0", "rich>=13.0.0", "croniter>=2.0.0", + "dingtalk-stream>=0.4.0", "python-telegram-bot[socks]>=21.0", "lark-oapi>=1.0.0", "socksio>=1.0.0", From 3b61ae4fff435a4dce9675ecd2bdabf9c097f414 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 04:29:51 +0000 Subject: [PATCH 13/58] fix: skip provider prefix rules for vLLM/OpenRouter/AiHubMix endpoints --- nanobot/providers/litellm_provider.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 7a52e7c..415100c 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -107,11 +107,12 @@ class LiteLLMProvider(LLMProvider): (("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")), (("gemini",), "gemini", ("gemini/",)), ] - model_lower = model.lower() - for keywords, prefix, skip in _prefix_rules: - if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip): - model = f"{prefix}/{model}" - break + if not (self.is_vllm or self.is_openrouter or self.is_aihubmix): + model_lower = model.lower() + for keywords, prefix, skip in _prefix_rules: + if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip): + model = f"{prefix}/{model}" + break # Gateway/endpoint-specific prefixes (detected by api_base/api_key, not model name) if self.is_openrouter and not model.startswith("openrouter/"): From f7f812a1774ebe20ba8e46a7e71f0ac5f1de37b5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 05:06:41 +0000 Subject: [PATCH 14/58] feat: add /reset and /help commands for Telegram bot --- README.md | 2 +- nanobot/agent/loop.py | 3 +- nanobot/channels/manager.py | 11 ++++- nanobot/channels/telegram.py | 81 ++++++++++++++++++++++++++++++++---- nanobot/cli/commands.py | 5 ++- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a1ea905..ff827be 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,422 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,423 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index b13113f..a65f3a5 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -45,6 +45,7 @@ class AgentLoop: exec_config: "ExecToolConfig | None" = None, cron_service: "CronService | None" = None, restrict_to_workspace: bool = False, + session_manager: SessionManager | None = None, ): from nanobot.config.schema import ExecToolConfig from nanobot.cron.service import CronService @@ -59,7 +60,7 @@ class AgentLoop: self.restrict_to_workspace = restrict_to_workspace self.context = ContextBuilder(workspace) - self.sessions = SessionManager(workspace) + self.sessions = session_manager or SessionManager(workspace) self.tools = ToolRegistry() self.subagents = SubagentManager( provider=provider, diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 846ea70..efb7db0 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -1,7 +1,9 @@ """Channel manager for coordinating chat channels.""" +from __future__ import annotations + import asyncio -from typing import Any +from typing import Any, TYPE_CHECKING from loguru import logger @@ -10,6 +12,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import Config +if TYPE_CHECKING: + from nanobot.session.manager import SessionManager + class ChannelManager: """ @@ -21,9 +26,10 @@ class ChannelManager: - Route outbound messages """ - def __init__(self, config: Config, bus: MessageBus): + def __init__(self, config: Config, bus: MessageBus, session_manager: "SessionManager | None" = None): self.config = config self.bus = bus + self.session_manager = session_manager self.channels: dict[str, BaseChannel] = {} self._dispatch_task: asyncio.Task | None = None @@ -40,6 +46,7 @@ class ChannelManager: self.config.channels.telegram, self.bus, groq_api_key=self.config.providers.groq.api_key, + session_manager=self.session_manager, ) logger.info("Telegram channel enabled") except ImportError as e: diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index f2b6d1f..4f62557 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -1,17 +1,23 @@ """Telegram channel implementation using python-telegram-bot.""" +from __future__ import annotations + import asyncio import re +from typing import TYPE_CHECKING from loguru import logger -from telegram import Update -from telegram.ext import Application, MessageHandler, filters, ContextTypes +from telegram import BotCommand, Update +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import TelegramConfig +if TYPE_CHECKING: + from nanobot.session.manager import SessionManager + def _markdown_to_telegram_html(text: str) -> str: """ @@ -85,10 +91,24 @@ class TelegramChannel(BaseChannel): name = "telegram" - def __init__(self, config: TelegramConfig, bus: MessageBus, groq_api_key: str = ""): + # Commands registered with Telegram's command menu + BOT_COMMANDS = [ + BotCommand("start", "Start the bot"), + BotCommand("reset", "Reset conversation history"), + BotCommand("help", "Show available commands"), + ] + + def __init__( + self, + config: TelegramConfig, + bus: MessageBus, + groq_api_key: str = "", + session_manager: SessionManager | None = None, + ): super().__init__(config, bus) self.config: TelegramConfig = config self.groq_api_key = groq_api_key + self.session_manager = session_manager self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies @@ -106,6 +126,11 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() + # Add command handlers + self._app.add_handler(CommandHandler("start", self._on_start)) + self._app.add_handler(CommandHandler("reset", self._on_reset)) + self._app.add_handler(CommandHandler("help", self._on_help)) + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( @@ -115,20 +140,22 @@ class TelegramChannel(BaseChannel): ) ) - # Add /start command handler - from telegram.ext import CommandHandler - self._app.add_handler(CommandHandler("start", self._on_start)) - logger.info("Starting Telegram bot (polling mode)...") # Initialize and start polling await self._app.initialize() await self._app.start() - # Get bot info + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info(f"Telegram bot @{bot_info.username} connected") + try: + await self._app.bot.set_my_commands(self.BOT_COMMANDS) + logger.debug("Telegram bot commands registered") + except Exception as e: + logger.warning(f"Failed to register bot commands: {e}") + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], @@ -187,9 +214,45 @@ class TelegramChannel(BaseChannel): user = update.effective_user await update.message.reply_text( f"๐Ÿ‘‹ Hi {user.first_name}! I'm nanobot.\n\n" - "Send me a message and I'll respond!" + "Send me a message and I'll respond!\n" + "Type /help to see available commands." ) + async def _on_reset(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /reset command โ€” clear conversation history.""" + if not update.message or not update.effective_user: + return + + chat_id = str(update.message.chat_id) + session_key = f"{self.name}:{chat_id}" + + if self.session_manager is None: + logger.warning("/reset called but session_manager is not available") + await update.message.reply_text("โš ๏ธ Session management is not available.") + return + + session = self.session_manager.get_or_create(session_key) + msg_count = len(session.messages) + session.clear() + self.session_manager.save(session) + + logger.info(f"Session reset for {session_key} (cleared {msg_count} messages)") + await update.message.reply_text("๐Ÿ”„ Conversation history cleared. Let's start fresh!") + + async def _on_help(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Handle /help command โ€” show available commands.""" + if not update.message: + return + + help_text = ( + "๐Ÿˆ nanobot commands\n\n" + "/start โ€” Start the bot\n" + "/reset โ€” Reset conversation history\n" + "/help โ€” Show this help message\n\n" + "Just send me a text message to chat!" + ) + await update.message.reply_text(help_text, parse_mode="HTML") + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 19e62e9..bfb3b1d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -179,6 +179,7 @@ def gateway( from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager + from nanobot.session.manager import SessionManager from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -192,6 +193,7 @@ def gateway( config = load_config() bus = MessageBus() provider = _make_provider(config) + session_manager = SessionManager(config.workspace_path) # Create cron service first (callback set after agent creation) cron_store_path = get_data_dir() / "cron" / "jobs.json" @@ -208,6 +210,7 @@ def gateway( exec_config=config.tools.exec, cron_service=cron, restrict_to_workspace=config.tools.restrict_to_workspace, + session_manager=session_manager, ) # Set cron callback (needs agent) @@ -242,7 +245,7 @@ def gateway( ) # Create channel manager - channels = ChannelManager(config, bus) + channels = ChannelManager(config, bus, session_manager=session_manager) if channels.enabled_channels: console.print(f"[green]โœ“[/green] Channels enabled: {', '.join(channels.enabled_channels)}") From 00185f2beea1fbab70a2aa9e229d35a7aa54d6fa Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 05:44:06 +0000 Subject: [PATCH 15/58] feat: add Telegram typing indicator --- .gitignore | 1 + nanobot/channels/telegram.py | 38 +++++++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 316e214..55338f7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ docs/ __pycache__/ poetry.lock .pytest_cache/ +tests/ \ No newline at end of file diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 4f62557..ff46c86 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -111,6 +111,7 @@ class TelegramChannel(BaseChannel): self.session_manager = session_manager 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 async def start(self) -> None: """Start the Telegram bot with long polling.""" @@ -170,6 +171,10 @@ class TelegramChannel(BaseChannel): """Stop the Telegram bot.""" self._running = False + # Cancel all typing indicators + for chat_id in list(self._typing_tasks): + self._stop_typing(chat_id) + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() @@ -183,6 +188,9 @@ class TelegramChannel(BaseChannel): logger.warning("Telegram bot not running") return + # Stop typing indicator for this chat + self._stop_typing(msg.chat_id) + try: # chat_id should be the Telegram chat ID (integer) chat_id = int(msg.chat_id) @@ -335,10 +343,15 @@ class TelegramChannel(BaseChannel): logger.debug(f"Telegram message from {sender_id}: {content[:50]}...") + str_chat_id = str(chat_id) + + # Start typing indicator before processing + self._start_typing(str_chat_id) + # Forward to the message bus await self._handle_message( sender_id=sender_id, - chat_id=str(chat_id), + chat_id=str_chat_id, content=content, media=media_paths, metadata={ @@ -350,6 +363,29 @@ class TelegramChannel(BaseChannel): } ) + def _start_typing(self, chat_id: str) -> None: + """Start sending 'typing...' indicator for a chat.""" + # Cancel any existing typing task for this chat + self._stop_typing(chat_id) + self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) + + def _stop_typing(self, chat_id: str) -> None: + """Stop the typing indicator for a chat.""" + task = self._typing_tasks.pop(chat_id, None) + if task and not task.done(): + task.cancel() + + async def _typing_loop(self, chat_id: str) -> None: + """Repeatedly send 'typing' action until cancelled.""" + try: + while self._app: + await self._app.bot.send_chat_action(chat_id=int(chat_id), action="typing") + await asyncio.sleep(4) + except asyncio.CancelledError: + pass + except Exception as e: + logger.debug(f"Typing indicator stopped for {chat_id}: {e}") + def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" if mime_type: From 299d8b33b31418bd6e4f0b38260a937f8789dca4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 07:29:31 +0000 Subject: [PATCH 16/58] refactor: replace provider if-elif chains with declarative registry --- README.md | 48 ++++ nanobot/cli/commands.py | 33 ++- nanobot/config/schema.py | 47 ++-- nanobot/providers/litellm_provider.py | 133 ++++++----- nanobot/providers/registry.py | 323 ++++++++++++++++++++++++++ 5 files changed, 474 insertions(+), 110 deletions(-) create mode 100644 nanobot/providers/registry.py diff --git a/README.md b/README.md index ff827be..90ca9e3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## ๐Ÿ“ข News +- **2026-02-08** ๐Ÿ”ง Refactored Providers โ€” adding a new LLM provider only takes just 2 steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** โœจ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! @@ -355,6 +356,53 @@ Config file: `~/.nanobot/config.json` | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `aihubmix` | LLM (API gateway, access to all models) | [aihubmix.com](https://aihubmix.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | +| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) | +| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | +| `vllm` | LLM (local, any OpenAI-compatible server) | โ€” | + +
+Adding a New Provider (Developer Guide) + +nanobot uses a **Provider Registry** (`nanobot/providers/registry.py`) as the single source of truth. +Adding a new provider only takes **2 steps** โ€” no if-elif chains to touch. + +**Step 1.** Add a `ProviderSpec` entry to `PROVIDERS` in `nanobot/providers/registry.py`: + +```python +ProviderSpec( + name="myprovider", # config field name + keywords=("myprovider", "mymodel"), # model-name keywords for auto-matching + env_key="MYPROVIDER_API_KEY", # env var for LiteLLM + display_name="My Provider", # shown in `nanobot status` + litellm_prefix="myprovider", # auto-prefix: model โ†’ myprovider/model + skip_prefixes=("myprovider/",), # don't double-prefix +) +``` + +**Step 2.** Add a field to `ProvidersConfig` in `nanobot/config/schema.py`: + +```python +class ProvidersConfig(BaseModel): + ... + myprovider: ProviderConfig = ProviderConfig() +``` + +That's it! Environment variables, model prefixing, config matching, and `nanobot status` display will all work automatically. + +**Common `ProviderSpec` options:** + +| Field | Description | Example | +|-------|-------------|---------| +| `litellm_prefix` | Auto-prefix model names for LiteLLM | `"dashscope"` โ†’ `dashscope/qwen-max` | +| `skip_prefixes` | Don't prefix if model already starts with these | `("dashscope/", "openrouter/")` | +| `env_extras` | Additional env vars to set | `(("ZHIPUAI_API_KEY", "{api_key}"),)` | +| `model_overrides` | Per-model parameter overrides | `(("kimi-k2.5", {"temperature": 1.0}),)` | +| `is_gateway` | Can route any model (like OpenRouter) | `True` | +| `detect_by_key_prefix` | Detect gateway by API key prefix | `"sk-or-"` | +| `detect_by_base_keyword` | Detect gateway by API base URL | `"openrouter"` | +| `strip_model_prefix` | Strip existing prefix before re-prefixing | `True` (for AiHubMix) | + +
### Security diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index bfb3b1d..1dab818 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -635,25 +635,24 @@ def status(): console.print(f"Workspace: {workspace} {'[green]โœ“[/green]' if workspace.exists() else '[red]โœ—[/red]'}") if config_path.exists(): + from nanobot.providers.registry import PROVIDERS + console.print(f"Model: {config.agents.defaults.model}") - # Check API keys - has_openrouter = bool(config.providers.openrouter.api_key) - has_anthropic = bool(config.providers.anthropic.api_key) - has_openai = bool(config.providers.openai.api_key) - has_gemini = bool(config.providers.gemini.api_key) - has_zhipu = bool(config.providers.zhipu.api_key) - has_vllm = bool(config.providers.vllm.api_base) - has_aihubmix = bool(config.providers.aihubmix.api_key) - - console.print(f"OpenRouter API: {'[green]โœ“[/green]' if has_openrouter else '[dim]not set[/dim]'}") - console.print(f"Anthropic API: {'[green]โœ“[/green]' if has_anthropic else '[dim]not set[/dim]'}") - console.print(f"OpenAI API: {'[green]โœ“[/green]' if has_openai else '[dim]not set[/dim]'}") - console.print(f"Gemini API: {'[green]โœ“[/green]' if has_gemini else '[dim]not set[/dim]'}") - console.print(f"Zhipu AI API: {'[green]โœ“[/green]' if has_zhipu else '[dim]not set[/dim]'}") - console.print(f"AiHubMix API: {'[green]โœ“[/green]' if has_aihubmix else '[dim]not set[/dim]'}") - vllm_status = f"[green]โœ“ {config.providers.vllm.api_base}[/green]" if has_vllm else "[dim]not set[/dim]" - console.print(f"vLLM/Local: {vllm_status}") + # Check API keys from registry + for spec in PROVIDERS: + p = getattr(config.providers, spec.name, None) + if p is None: + continue + if spec.is_local: + # Local deployments show api_base instead of api_key + if p.api_base: + console.print(f"{spec.label}: [green]โœ“ {p.api_base}[/green]") + else: + console.print(f"{spec.label}: [dim]not set[/dim]") + else: + has_key = bool(p.api_key) + console.print(f"{spec.label}: {'[green]โœ“[/green]' if has_key else '[dim]not set[/dim]'}") if __name__ == "__main__": diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7724288..ea8f8ba 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -125,29 +125,23 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - # Default base URLs for API gateways - _GATEWAY_DEFAULTS = {"openrouter": "https://openrouter.ai/api/v1", "aihubmix": "https://aihubmix.com/v1"} - def get_provider(self, model: str | None = None) -> ProviderConfig | None: """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available.""" - model = (model or self.agents.defaults.model).lower() - p = self.providers - # Keyword โ†’ provider mapping (order matters: gateways first) - keyword_map = { - "aihubmix": p.aihubmix, "openrouter": p.openrouter, - "deepseek": p.deepseek, "anthropic": p.anthropic, "claude": p.anthropic, - "openai": p.openai, "gpt": p.openai, "gemini": p.gemini, - "zhipu": p.zhipu, "glm": p.zhipu, "zai": p.zhipu, - "dashscope": p.dashscope, "qwen": p.dashscope, - "groq": p.groq, "moonshot": p.moonshot, "kimi": p.moonshot, "vllm": p.vllm, - } - for kw, provider in keyword_map.items(): - if kw in model and provider.api_key: - return provider - # Fallback: gateways first (can serve any model), then specific providers - all_providers = [p.openrouter, p.aihubmix, p.anthropic, p.openai, p.deepseek, - p.gemini, p.zhipu, p.dashscope, p.moonshot, p.vllm, p.groq] - return next((pr for pr in all_providers if pr.api_key), None) + from nanobot.providers.registry import PROVIDERS + model_lower = (model or self.agents.defaults.model).lower() + + # Match by keyword (order follows PROVIDERS registry) + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and any(kw in model_lower for kw in spec.keywords) and p.api_key: + return p + + # Fallback: gateways first, then others (follows registry order) + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and p.api_key: + return p + return None def get_api_key(self, model: str | None = None) -> str | None: """Get API key for the given model. Falls back to first available key.""" @@ -156,13 +150,16 @@ class Config(BaseSettings): def get_api_base(self, model: str | None = None) -> str | None: """Get API base URL for the given model. Applies default URLs for known gateways.""" + from nanobot.providers.registry import PROVIDERS p = self.get_provider(model) if p and p.api_base: return p.api_base - # Default URLs for known gateways (openrouter, aihubmix) - for name, url in self._GATEWAY_DEFAULTS.items(): - if p == getattr(self.providers, name): - return url + # Only gateways get a default URL here. Standard providers (like Moonshot) + # handle their base URL via env vars in _setup_env, NOT via api_base โ€” + # otherwise find_gateway() would misdetect them as local/vLLM. + for spec in PROVIDERS: + if spec.is_gateway and spec.default_api_base and p == getattr(self.providers, spec.name, None): + return spec.default_api_base return None class Config: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 415100c..5e9c22f 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -1,5 +1,6 @@ """LiteLLM provider implementation for multi-provider support.""" +import json import os from typing import Any @@ -7,6 +8,7 @@ import litellm from litellm import acompletion from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from nanobot.providers.registry import find_by_model, find_gateway class LiteLLMProvider(LLMProvider): @@ -14,7 +16,8 @@ class LiteLLMProvider(LLMProvider): LLM provider using LiteLLM for multi-provider support. Supports OpenRouter, Anthropic, OpenAI, Gemini, and many other providers through - a unified interface. + a unified interface. Provider-specific logic is driven by the registry + (see providers/registry.py) โ€” no if-elif chains needed here. """ def __init__( @@ -28,47 +31,17 @@ class LiteLLMProvider(LLMProvider): self.default_model = default_model self.extra_headers = extra_headers or {} - # Detect OpenRouter by api_key prefix or explicit api_base - self.is_openrouter = ( - (api_key and api_key.startswith("sk-or-")) or - (api_base and "openrouter" in api_base) - ) + # Detect gateway / local deployment from api_key and api_base + self._gateway = find_gateway(api_key, api_base) - # Detect AiHubMix by api_base - self.is_aihubmix = bool(api_base and "aihubmix" in api_base) + # Backwards-compatible flags (used by tests and possibly external code) + self.is_openrouter = bool(self._gateway and self._gateway.name == "openrouter") + self.is_aihubmix = bool(self._gateway and self._gateway.name == "aihubmix") + self.is_vllm = bool(self._gateway and self._gateway.is_local) - # Track if using custom endpoint (vLLM, etc.) - self.is_vllm = bool(api_base) and not self.is_openrouter and not self.is_aihubmix - - # Configure LiteLLM based on provider + # Configure environment variables if api_key: - if self.is_openrouter: - # OpenRouter mode - set key - os.environ["OPENROUTER_API_KEY"] = api_key - elif self.is_aihubmix: - # AiHubMix gateway - OpenAI-compatible - os.environ["OPENAI_API_KEY"] = api_key - elif self.is_vllm: - # vLLM/custom endpoint - uses OpenAI-compatible API - os.environ["HOSTED_VLLM_API_KEY"] = api_key - elif "deepseek" in default_model: - os.environ.setdefault("DEEPSEEK_API_KEY", api_key) - elif "anthropic" in default_model: - os.environ.setdefault("ANTHROPIC_API_KEY", api_key) - elif "openai" in default_model or "gpt" in default_model: - os.environ.setdefault("OPENAI_API_KEY", api_key) - elif "gemini" in default_model.lower(): - os.environ.setdefault("GEMINI_API_KEY", api_key) - elif "zhipu" in default_model or "glm" in default_model or "zai" in default_model: - os.environ.setdefault("ZAI_API_KEY", api_key) - os.environ.setdefault("ZHIPUAI_API_KEY", api_key) - elif "dashscope" in default_model or "qwen" in default_model.lower(): - os.environ.setdefault("DASHSCOPE_API_KEY", api_key) - elif "groq" in default_model: - os.environ.setdefault("GROQ_API_KEY", api_key) - elif "moonshot" in default_model or "kimi" in default_model: - os.environ.setdefault("MOONSHOT_API_KEY", api_key) - os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1") + self._setup_env(api_key, api_base, default_model) if api_base: litellm.api_base = api_base @@ -76,6 +49,55 @@ class LiteLLMProvider(LLMProvider): # Disable LiteLLM logging noise litellm.suppress_debug_info = True + def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None: + """Set environment variables based on detected provider.""" + if self._gateway: + # Gateway / local: direct set (not setdefault) + os.environ[self._gateway.env_key] = api_key + return + + # Standard provider: match by model name + spec = find_by_model(model) + if spec: + os.environ.setdefault(spec.env_key, api_key) + # Resolve env_extras placeholders: + # {api_key} โ†’ user's API key + # {api_base} โ†’ user's api_base, falling back to spec.default_api_base + effective_base = api_base or spec.default_api_base + for env_name, env_val in spec.env_extras: + resolved = env_val.replace("{api_key}", api_key) + resolved = resolved.replace("{api_base}", effective_base) + os.environ.setdefault(env_name, resolved) + + def _resolve_model(self, model: str) -> str: + """Resolve model name by applying provider/gateway prefixes.""" + if self._gateway: + # Gateway mode: apply gateway prefix, skip provider-specific prefixes + prefix = self._gateway.litellm_prefix + if self._gateway.strip_model_prefix: + model = model.split("/")[-1] + if prefix and not model.startswith(f"{prefix}/"): + model = f"{prefix}/{model}" + return model + + # Standard mode: auto-prefix for known providers + spec = find_by_model(model) + if spec and spec.litellm_prefix: + if not any(model.startswith(s) for s in spec.skip_prefixes): + model = f"{spec.litellm_prefix}/{model}" + + return model + + def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: + """Apply model-specific parameter overrides from the registry.""" + model_lower = model.lower() + spec = find_by_model(model) + if spec: + for pattern, overrides in spec.model_overrides: + if pattern in model_lower: + kwargs.update(overrides) + return + async def chat( self, messages: list[dict[str, Any]], @@ -97,35 +119,8 @@ class LiteLLMProvider(LLMProvider): Returns: LLMResponse with content and/or tool calls. """ - model = model or self.default_model + model = self._resolve_model(model or self.default_model) - # Auto-prefix model names for known providers - # (keywords, target_prefix, skip_if_starts_with) - _prefix_rules = [ - (("glm", "zhipu"), "zai", ("zhipu/", "zai/", "openrouter/", "hosted_vllm/")), - (("qwen", "dashscope"), "dashscope", ("dashscope/", "openrouter/")), - (("moonshot", "kimi"), "moonshot", ("moonshot/", "openrouter/")), - (("gemini",), "gemini", ("gemini/",)), - ] - if not (self.is_vllm or self.is_openrouter or self.is_aihubmix): - model_lower = model.lower() - for keywords, prefix, skip in _prefix_rules: - if any(kw in model_lower for kw in keywords) and not any(model.startswith(s) for s in skip): - model = f"{prefix}/{model}" - break - - # Gateway/endpoint-specific prefixes (detected by api_base/api_key, not model name) - if self.is_openrouter and not model.startswith("openrouter/"): - model = f"openrouter/{model}" - elif self.is_aihubmix: - model = f"openai/{model.split('/')[-1]}" - elif self.is_vllm: - model = f"hosted_vllm/{model}" - - # kimi-k2.5 only supports temperature=1.0 - if "kimi-k2.5" in model.lower(): - temperature = 1.0 - kwargs: dict[str, Any] = { "model": model, "messages": messages, @@ -133,6 +128,9 @@ class LiteLLMProvider(LLMProvider): "temperature": temperature, } + # Apply model-specific overrides (e.g. kimi-k2.5 temperature) + self._apply_model_overrides(model, kwargs) + # Pass api_base directly for custom endpoints (vLLM, etc.) if self.api_base: kwargs["api_base"] = self.api_base @@ -166,7 +164,6 @@ class LiteLLMProvider(LLMProvider): # Parse arguments from JSON string if needed args = tc.function.arguments if isinstance(args, str): - import json try: args = json.loads(args) except json.JSONDecodeError: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py new file mode 100644 index 0000000..aa4a76e --- /dev/null +++ b/nanobot/providers/registry.py @@ -0,0 +1,323 @@ +""" +Provider Registry โ€” single source of truth for LLM provider metadata. + +Adding a new provider: + 1. Add a ProviderSpec to PROVIDERS below. + 2. Add a field to ProvidersConfig in config/schema.py. + Done. Env vars, prefixing, config matching, status display all derive from here. + +Order matters โ€” it controls match priority and fallback. Gateways first. +Every entry writes out all fields so you can copy-paste as a template. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +@dataclass(frozen=True) +class ProviderSpec: + """One LLM provider's metadata. See PROVIDERS below for real examples. + + Placeholders in env_extras values: + {api_key} โ€” the user's API key + {api_base} โ€” api_base from config, or this spec's default_api_base + """ + + # identity + name: str # config field name, e.g. "dashscope" + keywords: tuple[str, ...] # model-name keywords for matching (lowercase) + env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" + display_name: str = "" # shown in `nanobot status` + + # model prefixing + litellm_prefix: str = "" # "dashscope" โ†’ model becomes "dashscope/{model}" + skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these + + # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) + env_extras: tuple[tuple[str, str], ...] = () + + # gateway / local detection + is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) + is_local: bool = False # local deployment (vLLM, Ollama) + detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" + detect_by_base_keyword: str = "" # match substring in api_base URL + default_api_base: str = "" # fallback base URL + + # gateway behavior + strip_model_prefix: bool = False # strip "provider/" before re-prefixing + + # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) + model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () + + @property + def label(self) -> str: + return self.display_name or self.name.title() + + +# --------------------------------------------------------------------------- +# PROVIDERS โ€” the registry. Order = priority. Copy any entry as template. +# --------------------------------------------------------------------------- + +PROVIDERS: tuple[ProviderSpec, ...] = ( + + # === Gateways (detected by api_key / api_base, not model name) ========= + # Gateways can route any model, so they win in fallback. + + # OpenRouter: global gateway, keys start with "sk-or-" + ProviderSpec( + name="openrouter", + keywords=("openrouter",), + env_key="OPENROUTER_API_KEY", + display_name="OpenRouter", + litellm_prefix="openrouter", # claude-3 โ†’ openrouter/claude-3 + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="sk-or-", + detect_by_base_keyword="openrouter", + default_api_base="https://openrouter.ai/api/v1", + strip_model_prefix=False, + model_overrides=(), + ), + + # AiHubMix: global gateway, OpenAI-compatible interface. + # strip_model_prefix=True: it doesn't understand "anthropic/claude-3", + # so we strip to bare "claude-3" then re-prefix as "openai/claude-3". + ProviderSpec( + name="aihubmix", + keywords=("aihubmix",), + env_key="OPENAI_API_KEY", # OpenAI-compatible + display_name="AiHubMix", + litellm_prefix="openai", # โ†’ openai/{model} + skip_prefixes=(), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="aihubmix", + default_api_base="https://aihubmix.com/v1", + strip_model_prefix=True, # anthropic/claude-3 โ†’ claude-3 โ†’ openai/claude-3 + model_overrides=(), + ), + + # === Standard providers (matched by model-name keywords) =============== + + # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. + ProviderSpec( + name="anthropic", + keywords=("anthropic", "claude"), + env_key="ANTHROPIC_API_KEY", + display_name="Anthropic", + litellm_prefix="", + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), + + # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed. + ProviderSpec( + name="openai", + keywords=("openai", "gpt"), + env_key="OPENAI_API_KEY", + display_name="OpenAI", + litellm_prefix="", + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), + + # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. + ProviderSpec( + name="deepseek", + keywords=("deepseek",), + env_key="DEEPSEEK_API_KEY", + display_name="DeepSeek", + litellm_prefix="deepseek", # deepseek-chat โ†’ deepseek/deepseek-chat + skip_prefixes=("deepseek/",), # avoid double-prefix + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), + + # Gemini: needs "gemini/" prefix for LiteLLM. + ProviderSpec( + name="gemini", + keywords=("gemini",), + env_key="GEMINI_API_KEY", + display_name="Gemini", + litellm_prefix="gemini", # gemini-pro โ†’ gemini/gemini-pro + skip_prefixes=("gemini/",), # avoid double-prefix + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), + + # Zhipu: LiteLLM uses "zai/" prefix. + # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that). + # skip_prefixes: don't add "zai/" when already routed via gateway. + ProviderSpec( + name="zhipu", + keywords=("zhipu", "glm", "zai"), + env_key="ZAI_API_KEY", + display_name="Zhipu AI", + litellm_prefix="zai", # glm-4 โ†’ zai/glm-4 + skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), + env_extras=( + ("ZHIPUAI_API_KEY", "{api_key}"), + ), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), + + # DashScope: Qwen models, needs "dashscope/" prefix. + ProviderSpec( + name="dashscope", + keywords=("qwen", "dashscope"), + env_key="DASHSCOPE_API_KEY", + display_name="DashScope", + litellm_prefix="dashscope", # qwen-max โ†’ dashscope/qwen-max + skip_prefixes=("dashscope/", "openrouter/"), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), + + # Moonshot: Kimi models, needs "moonshot/" prefix. + # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint. + # Kimi K2.5 API enforces temperature >= 1.0. + ProviderSpec( + name="moonshot", + keywords=("moonshot", "kimi"), + env_key="MOONSHOT_API_KEY", + display_name="Moonshot", + litellm_prefix="moonshot", # kimi-k2.5 โ†’ moonshot/kimi-k2.5 + skip_prefixes=("moonshot/", "openrouter/"), + env_extras=( + ("MOONSHOT_API_BASE", "{api_base}"), + ), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China + strip_model_prefix=False, + model_overrides=( + ("kimi-k2.5", {"temperature": 1.0}), + ), + ), + + # === Local deployment (fallback: unknown api_base โ†’ assume local) ====== + + # vLLM / any OpenAI-compatible local server. + # If api_base is set but doesn't match a known gateway, we land here. + # Placed before Groq so vLLM wins the fallback when both are configured. + ProviderSpec( + name="vllm", + keywords=("vllm",), + env_key="HOSTED_VLLM_API_KEY", + display_name="vLLM/Local", + litellm_prefix="hosted_vllm", # Llama-3-8B โ†’ hosted_vllm/Llama-3-8B + skip_prefixes=(), + env_extras=(), + is_gateway=False, + is_local=True, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", # user must provide in config + strip_model_prefix=False, + model_overrides=(), + ), + + # === Auxiliary (not a primary LLM provider) ============================ + + # Groq: mainly used for Whisper voice transcription, also usable for LLM. + # Needs "groq/" prefix for LiteLLM routing. Placed last โ€” it rarely wins fallback. + ProviderSpec( + name="groq", + keywords=("groq",), + env_key="GROQ_API_KEY", + display_name="Groq", + litellm_prefix="groq", # llama3-8b-8192 โ†’ groq/llama3-8b-8192 + skip_prefixes=("groq/",), # avoid double-prefix + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + ), +) + + +# --------------------------------------------------------------------------- +# Lookup helpers +# --------------------------------------------------------------------------- + +def find_by_model(model: str) -> ProviderSpec | None: + """Match a standard provider by model-name keyword (case-insensitive). + Skips gateways/local โ€” those are matched by api_key/api_base instead.""" + model_lower = model.lower() + for spec in PROVIDERS: + if spec.is_gateway or spec.is_local: + continue + if any(kw in model_lower for kw in spec.keywords): + return spec + return None + + +def find_gateway(api_key: str | None, api_base: str | None) -> ProviderSpec | None: + """Detect gateway/local by api_key prefix or api_base substring. + Fallback: unknown api_base โ†’ treat as local (vLLM).""" + for spec in PROVIDERS: + if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix): + return spec + if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base: + return spec + if api_base: + return next((s for s in PROVIDERS if s.is_local), None) + return None + + +def find_by_name(name: str) -> ProviderSpec | None: + """Find a provider spec by config field name, e.g. "dashscope".""" + for spec in PROVIDERS: + if spec.name == name: + return spec + return None From f49c639b74ced46df483ad12523580cd5e51da81 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Sun, 8 Feb 2026 18:02:48 +0800 Subject: [PATCH 17/58] Update README.md --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 90ca9e3..8824570 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ ## ๐Ÿ“ข News -- **2026-02-08** ๐Ÿ”ง Refactored Providers โ€” adding a new LLM provider only takes just 2 steps! Check [here](#providers). -- **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. +- **2026-02-08** ๐Ÿ”ง Refactored Providers โ€” adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). +- **2026-02-07** ๐Ÿš€ ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** โœจ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! - **2026-02-04** ๐Ÿš€ Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. From 9e3823ae034e16287cebbe1b36e0c486e99139b5 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Sun, 8 Feb 2026 18:03:00 +0800 Subject: [PATCH 18/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8824570..d1ae7ce 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ ## ๐Ÿ“ข News - **2026-02-08** ๐Ÿ”ง Refactored Providers โ€” adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). -- **2026-02-07** ๐Ÿš€ ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. +- **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** โœจ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! - **2026-02-04** ๐Ÿš€ Released v0.1.3.post4 with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details. From 3675758a44d2c4d49dd867e776c18a764014975e Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Sun, 8 Feb 2026 18:10:24 +0800 Subject: [PATCH 19/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d1ae7ce..a833dbe 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-08** ๐Ÿ”ง Refactored Providers โ€” adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). +- **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! - **2026-02-05** โœจ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support! From b6ec6a8a7686b8d3239bd9f363fa55490f9f9217 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 18:06:07 +0000 Subject: [PATCH 20/58] fix(dingtalk): security and resource fixes for DingTalk channel --- README.md | 10 +- nanobot/channels/dingtalk.py | 195 +++++++++++++++++++---------------- 2 files changed, 108 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 8c5c387..326f253 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,423 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,429 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News @@ -293,10 +293,6 @@ nanobot gateway Uses **WebSocket** long connection โ€” no public IP required. -```bash -pip install nanobot-ai[feishu] -``` - **1. Create a Feishu bot** - Visit [Feishu Open Platform](https://open.feishu.cn/app) - Create a new app โ†’ Enable **Bot** capability @@ -342,10 +338,6 @@ nanobot gateway Uses **Stream Mode** โ€” no public IP required. -```bash -pip install nanobot-ai[dingtalk] -``` - **1. Create a DingTalk bot** - Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/) - Create a new app -> Add **Robot** capability diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 897e5be..72d3afd 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -2,30 +2,35 @@ import asyncio import json -import threading import time from typing import Any from loguru import logger import httpx -from nanobot.bus.events import OutboundMessage, InboundMessage +from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import DingTalkConfig try: from dingtalk_stream import ( - DingTalkStreamClient, + DingTalkStreamClient, Credential, CallbackHandler, CallbackMessage, - AckMessage + AckMessage, ) from dingtalk_stream.chatbot import ChatbotMessage + DINGTALK_AVAILABLE = True except ImportError: DINGTALK_AVAILABLE = False + # Fallback so class definitions don't crash at module level + CallbackHandler = object # type: ignore[assignment,misc] + CallbackMessage = None # type: ignore[assignment,misc] + AckMessage = None # type: ignore[assignment,misc] + ChatbotMessage = None # type: ignore[assignment,misc] class NanobotDingTalkHandler(CallbackHandler): @@ -33,127 +38,146 @@ class NanobotDingTalkHandler(CallbackHandler): Standard DingTalk Stream SDK Callback Handler. Parses incoming messages and forwards them to the Nanobot channel. """ + def __init__(self, channel: "DingTalkChannel"): super().__init__() self.channel = channel - + async def process(self, message: CallbackMessage): """Process incoming stream message.""" try: # Parse using SDK's ChatbotMessage for robust handling chatbot_msg = ChatbotMessage.from_dict(message.data) - - # Extract content based on message type + + # Extract text content; fall back to raw dict if SDK object is empty content = "" if chatbot_msg.text: content = chatbot_msg.text.content.strip() - elif chatbot_msg.message_type == "text": - # Fallback manual extraction if object not populated - content = message.data.get("text", {}).get("content", "").strip() - if not content: - logger.warning(f"Received empty or unsupported message type: {chatbot_msg.message_type}") + content = message.data.get("text", {}).get("content", "").strip() + + if not content: + logger.warning( + f"Received empty or unsupported message type: {chatbot_msg.message_type}" + ) return AckMessage.STATUS_OK, "OK" sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_name = chatbot_msg.sender_nick or "Unknown" - + logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}") - # Forward to Nanobot - # We use asyncio.create_task to avoid blocking the ACK return - asyncio.create_task( + # Forward to Nanobot via _on_message (non-blocking). + # Store reference to prevent GC before task completes. + task = asyncio.create_task( self.channel._on_message(content, sender_id, sender_name) ) + self.channel._background_tasks.add(task) + task.add_done_callback(self.channel._background_tasks.discard) return AckMessage.STATUS_OK, "OK" - + except Exception as e: logger.error(f"Error processing DingTalk message: {e}") - # Return OK to avoid retry loop from DingTalk server if it's a parsing error + # Return OK to avoid retry loop from DingTalk server return AckMessage.STATUS_OK, "Error" + class DingTalkChannel(BaseChannel): """ DingTalk channel using Stream Mode. - + Uses WebSocket to receive events via `dingtalk-stream` SDK. - Uses direct HTTP API to send messages (since SDK is mainly for receiving). + Uses direct HTTP API to send messages (SDK is mainly for receiving). + + Note: Currently only supports private (1:1) chat. Group messages are + received but replies are sent back as private messages to the sender. """ - + name = "dingtalk" - + def __init__(self, config: DingTalkConfig, bus: MessageBus): super().__init__(config, bus) self.config: DingTalkConfig = config self._client: Any = None - self._loop: asyncio.AbstractEventLoop | None = None - + self._http: httpx.AsyncClient | None = None + # Access Token management for sending messages self._access_token: str | None = None self._token_expiry: float = 0 - + + # Hold references to background tasks to prevent GC + self._background_tasks: set[asyncio.Task] = set() + async def start(self) -> None: """Start the DingTalk bot with Stream Mode.""" try: if not DINGTALK_AVAILABLE: - logger.error("DingTalk Stream SDK not installed. Run: pip install dingtalk-stream") + logger.error( + "DingTalk Stream SDK not installed. Run: pip install dingtalk-stream" + ) return - + if not self.config.client_id or not self.config.client_secret: logger.error("DingTalk client_id and client_secret not configured") return - + self._running = True - self._loop = asyncio.get_running_loop() - - logger.info(f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}...") + self._http = httpx.AsyncClient() + + logger.info( + f"Initializing DingTalk Stream Client with Client ID: {self.config.client_id}..." + ) credential = Credential(self.config.client_id, self.config.client_secret) self._client = DingTalkStreamClient(credential) - + # Register standard handler handler = NanobotDingTalkHandler(self) - - # Register using the chatbot topic standard for bots - self._client.register_callback_handler( - ChatbotMessage.TOPIC, - handler - ) - + self._client.register_callback_handler(ChatbotMessage.TOPIC, handler) + logger.info("DingTalk bot started with Stream Mode") - - # The client.start() method is an async infinite loop that handles the websocket connection + + # client.start() is an async infinite loop handling the websocket connection await self._client.start() except Exception as e: logger.exception(f"Failed to start DingTalk channel: {e}") - + async def stop(self) -> None: """Stop the DingTalk bot.""" self._running = False - # SDK doesn't expose a clean stop method that cancels loop immediately without private access - pass + # Close the shared HTTP client + if self._http: + await self._http.aclose() + self._http = None + # Cancel outstanding background tasks + for task in self._background_tasks: + task.cancel() + self._background_tasks.clear() async def _get_access_token(self) -> str | None: """Get or refresh Access Token.""" if self._access_token and time.time() < self._token_expiry: return self._access_token - + url = "https://api.dingtalk.com/v1.0/oauth2/accessToken" data = { "appKey": self.config.client_id, - "appSecret": self.config.client_secret + "appSecret": self.config.client_secret, } - + + if not self._http: + logger.warning("DingTalk HTTP client not initialized, cannot refresh token") + return None + try: - async with httpx.AsyncClient() as client: - resp = await client.post(url, json=data) - resp.raise_for_status() - res_data = resp.json() - self._access_token = res_data.get("accessToken") - # Expire 60s early to be safe - self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 - return self._access_token + resp = await self._http.post(url, json=data) + resp.raise_for_status() + res_data = resp.json() + self._access_token = res_data.get("accessToken") + # Expire 60s early to be safe + self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 + return self._access_token except Exception as e: logger.error(f"Failed to get DingTalk access token: {e}") return None @@ -163,57 +187,52 @@ class DingTalkChannel(BaseChannel): token = await self._get_access_token() if not token: return - - # This endpoint is for sending to a single user in a bot chat + + # oToMessages/batchSend: sends to individual users (private chat) # https://open.dingtalk.com/document/orgapp/robot-batch-send-messages url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" - - headers = { - "x-acs-dingtalk-access-token": token - } - - # Convert markdown code blocks for basic compatibility if needed, - # but DingTalk supports markdown loosely. - + + headers = {"x-acs-dingtalk-access-token": token} + data = { "robotCode": self.config.client_id, - "userIds": [msg.chat_id], # chat_id is the user's staffId/unionId - "msgKey": "sampleMarkdown", # Using markdown template + "userIds": [msg.chat_id], # chat_id is the user's staffId + "msgKey": "sampleMarkdown", "msgParam": json.dumps({ "text": msg.content, - "title": "Nanobot Reply" - }) + "title": "Nanobot Reply", + }), } - + + if not self._http: + logger.warning("DingTalk HTTP client not initialized, cannot send") + return + try: - async with httpx.AsyncClient() as client: - resp = await client.post(url, json=data, headers=headers) - # Check 200 OK but also API error codes if any - if resp.status_code != 200: - logger.error(f"DingTalk send failed: {resp.text}") - else: - logger.debug(f"DingTalk message sent to {msg.chat_id}") + resp = await self._http.post(url, json=data, headers=headers) + if resp.status_code != 200: + logger.error(f"DingTalk send failed: {resp.text}") + else: + logger.debug(f"DingTalk message sent to {msg.chat_id}") except Exception as e: logger.error(f"Error sending DingTalk message: {e}") async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: - """Handle incoming message (called by NanobotDingTalkHandler).""" + """Handle incoming message (called by NanobotDingTalkHandler). + + Delegates to BaseChannel._handle_message() which enforces allow_from + permission checks before publishing to the bus. + """ try: logger.info(f"DingTalk inbound: {content} from {sender_name}") - - # Correct InboundMessage usage based on events.py definition - # @dataclass class InboundMessage: - # channel: str, sender_id: str, chat_id: str, content: str, ... - msg = InboundMessage( - channel=self.name, + await self._handle_message( sender_id=sender_id, - chat_id=sender_id, # For private stats, chat_id is sender_id + chat_id=sender_id, # For private chat, chat_id == sender_id content=str(content), metadata={ "sender_name": sender_name, - "platform": "dingtalk" - } + "platform": "dingtalk", + }, ) - await self.bus.publish_inbound(msg) except Exception as e: logger.error(f"Error publishing DingTalk message: {e}") From dfa173323c1641983b51223b1a6310b61e43e56b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 18:23:43 +0000 Subject: [PATCH 21/58] =?UTF-8?q?refactor(cli):=20simplify=20input=20handl?= =?UTF-8?q?ing=20=E2=80=94=20drop=20prompt-toolkit,=20use=20readline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/cli/commands.py | 201 ++-------------------------------------- pyproject.toml | 1 - 2 files changed, 10 insertions(+), 192 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 5d198a5..c90ecde 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -6,7 +6,6 @@ import os from pathlib import Path import select import sys -from typing import Any import typer from rich.console import Console @@ -21,12 +20,15 @@ app = typer.Typer( ) console = Console() -_READLINE: Any | None = None + +# --------------------------------------------------------------------------- +# Lightweight CLI input: readline for arrow keys / history, termios for flush +# --------------------------------------------------------------------------- + +_READLINE = None _HISTORY_FILE: Path | None = None _HISTORY_HOOK_REGISTERED = False _USING_LIBEDIT = False -_PROMPT_SESSION: Any | None = None -_PROMPT_SESSION_LABEL: Any = None def _flush_pending_tty_input() -> None: @@ -40,7 +42,6 @@ def _flush_pending_tty_input() -> None: try: import termios - termios.tcflush(fd, termios.TCIFLUSH) return except Exception: @@ -67,75 +68,16 @@ def _save_history() -> None: def _enable_line_editing() -> None: - """Best-effort enable readline/libedit line editing for arrow keys/history.""" + """Enable readline for arrow keys, line editing, and persistent history.""" global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT - global _PROMPT_SESSION, _PROMPT_SESSION_LABEL history_file = Path.home() / ".nanobot" / "history" / "cli_history" history_file.parent.mkdir(parents=True, exist_ok=True) _HISTORY_FILE = history_file - # Preferred path: prompt_toolkit handles wrapped wide-char rendering better. - try: - from prompt_toolkit import PromptSession - from prompt_toolkit.formatted_text import ANSI - from prompt_toolkit.history import FileHistory - from prompt_toolkit.key_binding import KeyBindings - - key_bindings = KeyBindings() - - @key_bindings.add("enter") - def _accept_input(event) -> None: - _clear_visual_nav_state(event.current_buffer) - event.current_buffer.validate_and_handle() - - @key_bindings.add("up") - def _handle_up(event) -> None: - count = event.arg if event.arg and event.arg > 0 else 1 - moved = _move_buffer_cursor_visual_from_render( - buffer=event.current_buffer, - event=event, - delta=-1, - count=count, - ) - if not moved: - event.current_buffer.history_backward(count=count) - _clear_visual_nav_state(event.current_buffer) - - @key_bindings.add("down") - def _handle_down(event) -> None: - count = event.arg if event.arg and event.arg > 0 else 1 - moved = _move_buffer_cursor_visual_from_render( - buffer=event.current_buffer, - event=event, - delta=1, - count=count, - ) - if not moved: - event.current_buffer.history_forward(count=count) - _clear_visual_nav_state(event.current_buffer) - - _PROMPT_SESSION = PromptSession( - history=FileHistory(str(history_file)), - multiline=True, - wrap_lines=True, - complete_while_typing=False, - key_bindings=key_bindings, - ) - _PROMPT_SESSION.default_buffer.on_text_changed += ( - lambda _event: _clear_visual_nav_state(_PROMPT_SESSION.default_buffer) - ) - _PROMPT_SESSION_LABEL = ANSI("\x1b[1;34mYou:\x1b[0m ") - _READLINE = None - _USING_LIBEDIT = False - return - except Exception: - _PROMPT_SESSION = None - _PROMPT_SESSION_LABEL = None - try: import readline - except Exception: + except ImportError: return _READLINE = readline @@ -170,137 +112,14 @@ def _prompt_text() -> str: return "\001\033[1;34m\002You:\001\033[0m\002 " -def _read_interactive_input() -> str: - """Read user input with stable prompt rendering (sync fallback).""" - return input(_prompt_text()) - - async def _read_interactive_input_async() -> str: - """Read user input safely inside the interactive asyncio loop.""" - if _PROMPT_SESSION is not None: - try: - return await _PROMPT_SESSION.prompt_async(_PROMPT_SESSION_LABEL) - except EOFError as exc: - raise KeyboardInterrupt from exc + """Read user input with arrow keys and history (runs input() in a thread).""" try: - return await asyncio.to_thread(_read_interactive_input) + return await asyncio.to_thread(input, _prompt_text()) except EOFError as exc: raise KeyboardInterrupt from exc -def _choose_visual_rowcol( - rowcol_to_yx: dict[tuple[int, int], tuple[int, int]], - current_rowcol: tuple[int, int], - delta: int, - preferred_x: int | None = None, -) -> tuple[tuple[int, int] | None, int | None]: - """Choose next logical row/col by rendered screen coordinates.""" - if delta not in (-1, 1): - return None, preferred_x - - current_yx = rowcol_to_yx.get(current_rowcol) - if current_yx is None: - same_row = [ - (rowcol, yx) - for rowcol, yx in rowcol_to_yx.items() - if rowcol[0] == current_rowcol[0] - ] - if not same_row: - return None, preferred_x - _, current_yx = min(same_row, key=lambda item: abs(item[0][1] - current_rowcol[1])) - - target_x = current_yx[1] if preferred_x is None else preferred_x - target_y = current_yx[0] + delta - candidates = [(rowcol, yx) for rowcol, yx in rowcol_to_yx.items() if yx[0] == target_y] - if not candidates: - return None, preferred_x - - best_rowcol, _ = min( - candidates, - key=lambda item: (abs(item[1][1] - target_x), item[1][1] < target_x, item[1][1]), - ) - return best_rowcol, target_x - - -def _clear_visual_nav_state(buffer: Any) -> None: - """Reset cached vertical-navigation anchor state.""" - setattr(buffer, "_nanobot_visual_pref_x", None) - setattr(buffer, "_nanobot_visual_last_dir", None) - setattr(buffer, "_nanobot_visual_last_cursor", None) - setattr(buffer, "_nanobot_visual_last_text", None) - - -def _can_reuse_visual_anchor(buffer: Any, delta: int) -> bool: - """Reuse anchor only for uninterrupted vertical navigation.""" - return ( - getattr(buffer, "_nanobot_visual_last_dir", None) == delta - and getattr(buffer, "_nanobot_visual_last_cursor", None) == buffer.cursor_position - and getattr(buffer, "_nanobot_visual_last_text", None) == buffer.text - ) - - -def _remember_visual_anchor(buffer: Any, delta: int) -> None: - """Remember current state as anchor baseline for repeated up/down.""" - setattr(buffer, "_nanobot_visual_last_dir", delta) - setattr(buffer, "_nanobot_visual_last_cursor", buffer.cursor_position) - setattr(buffer, "_nanobot_visual_last_text", buffer.text) - - -def _move_buffer_cursor_visual_from_render( - buffer: Any, - event: Any, - delta: int, - count: int, -) -> bool: - """Move cursor across rendered screen rows (soft-wrap/CJK aware).""" - try: - window = event.app.layout.current_window - render_info = getattr(window, "render_info", None) - rowcol_to_yx = getattr(render_info, "_rowcol_to_yx", None) - if not isinstance(rowcol_to_yx, dict) or not rowcol_to_yx: - return False - except Exception: - return False - - moved_any = False - preferred_x = ( - getattr(buffer, "_nanobot_visual_pref_x", None) - if _can_reuse_visual_anchor(buffer, delta) - else None - ) - steps = max(1, count) - - for _ in range(steps): - doc = buffer.document - current_rowcol = (doc.cursor_position_row, doc.cursor_position_col) - next_rowcol, preferred_x = _choose_visual_rowcol( - rowcol_to_yx=rowcol_to_yx, - current_rowcol=current_rowcol, - delta=delta, - preferred_x=preferred_x, - ) - if next_rowcol is None: - break - - try: - new_position = doc.translate_row_col_to_index(*next_rowcol) - except Exception: - break - if new_position == buffer.cursor_position: - break - - buffer.cursor_position = new_position - moved_any = True - - if moved_any: - setattr(buffer, "_nanobot_visual_pref_x", preferred_x) - _remember_visual_anchor(buffer, delta) - else: - _clear_visual_nav_state(buffer) - - return moved_any - - def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") diff --git a/pyproject.toml b/pyproject.toml index 3669ee5..6fda084 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ "python-telegram-bot[socks]>=21.0", "lark-oapi>=1.0.0", "socksio>=1.0.0", - "prompt-toolkit>=3.0.47", ] [project.optional-dependencies] From b4217b26906d06d500d35de715801db42554ab25 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 18:26:06 +0000 Subject: [PATCH 22/58] chore: remove test file from tracking --- tests/test_cli_input_minimal.py | 56 --------------------------------- 1 file changed, 56 deletions(-) delete mode 100644 tests/test_cli_input_minimal.py diff --git a/tests/test_cli_input_minimal.py b/tests/test_cli_input_minimal.py deleted file mode 100644 index 4726ea3..0000000 --- a/tests/test_cli_input_minimal.py +++ /dev/null @@ -1,56 +0,0 @@ -import builtins - -import nanobot.cli.commands as commands - - -def test_read_interactive_input_uses_plain_input(monkeypatch) -> None: - captured: dict[str, str] = {} - def fake_input(prompt: str = "") -> str: - captured["prompt"] = prompt - return "hello" - - monkeypatch.setattr(builtins, "input", fake_input) - monkeypatch.setattr(commands, "_PROMPT_SESSION", None) - monkeypatch.setattr(commands, "_READLINE", None) - - value = commands._read_interactive_input() - - assert value == "hello" - assert captured["prompt"] == "You: " - - -def test_read_interactive_input_prefers_prompt_session(monkeypatch) -> None: - captured: dict[str, object] = {} - - class FakePromptSession: - async def prompt_async(self, label: object) -> str: - captured["label"] = label - return "hello" - - monkeypatch.setattr(commands, "_PROMPT_SESSION", FakePromptSession()) - monkeypatch.setattr(commands, "_PROMPT_SESSION_LABEL", "LBL") - - value = __import__("asyncio").run(commands._read_interactive_input_async()) - - assert value == "hello" - assert captured["label"] == "LBL" - - -def test_prompt_text_for_readline_modes(monkeypatch) -> None: - monkeypatch.setattr(commands, "_READLINE", object()) - monkeypatch.setattr(commands, "_USING_LIBEDIT", True) - assert commands._prompt_text() == "\033[1;34mYou:\033[0m " - - monkeypatch.setattr(commands, "_USING_LIBEDIT", False) - assert "\001" in commands._prompt_text() - - -def test_flush_pending_tty_input_skips_non_tty(monkeypatch) -> None: - class FakeStdin: - def fileno(self) -> int: - return 0 - - monkeypatch.setattr(commands.sys, "stdin", FakeStdin()) - monkeypatch.setattr(commands.os, "isatty", lambda _fd: False) - - commands._flush_pending_tty_input() From 2931694eb893b4d108e78df79c40621122589e8f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 18:37:41 +0000 Subject: [PATCH 23/58] fix: preserve reasoning_content in conversation history for thinking models --- README.md | 2 +- nanobot/agent/context.py | 8 +++++++- nanobot/agent/loop.py | 6 ++++-- nanobot/providers/base.py | 1 + nanobot/providers/litellm_provider.py | 3 +++ 5 files changed, 16 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 326f253..d3dcaf7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,429 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,437 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 3ea6c04..d807854 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -207,7 +207,8 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" self, messages: list[dict[str, Any]], content: str | None, - tool_calls: list[dict[str, Any]] | None = None + tool_calls: list[dict[str, Any]] | None = None, + reasoning_content: str | None = None, ) -> list[dict[str, Any]]: """ Add an assistant message to the message list. @@ -216,6 +217,7 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" messages: Current message list. content: Message content. tool_calls: Optional tool calls. + reasoning_content: Thinking output (Kimi, DeepSeek-R1, etc.). Returns: Updated message list. @@ -225,5 +227,9 @@ When remembering something, write to {workspace_path}/memory/MEMORY.md""" if tool_calls: msg["tool_calls"] = tool_calls + # Thinking models reject history without this + if reasoning_content: + msg["reasoning_content"] = reasoning_content + messages.append(msg) return messages diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index a65f3a5..72ea86a 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -213,7 +213,8 @@ class AgentLoop: for tc in response.tool_calls ] messages = self.context.add_assistant_message( - messages, response.content, tool_call_dicts + messages, response.content, tool_call_dicts, + reasoning_content=response.reasoning_content, ) # Execute tools @@ -317,7 +318,8 @@ class AgentLoop: for tc in response.tool_calls ] messages = self.context.add_assistant_message( - messages, response.content, tool_call_dicts + messages, response.content, tool_call_dicts, + reasoning_content=response.reasoning_content, ) for tool_call in response.tool_calls: diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 08e44ac..c69c38b 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -20,6 +20,7 @@ class LLMResponse: tool_calls: list[ToolCallRequest] = field(default_factory=list) finish_reason: str = "stop" usage: dict[str, int] = field(default_factory=dict) + reasoning_content: str | None = None # Kimi, DeepSeek-R1 etc. @property def has_tool_calls(self) -> bool: diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 5e9c22f..621a71d 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -183,11 +183,14 @@ class LiteLLMProvider(LLMProvider): "total_tokens": response.usage.total_tokens, } + reasoning_content = getattr(message, "reasoning_content", None) + return LLMResponse( content=message.content, tool_calls=tool_calls, finish_reason=choice.finish_reason or "stop", usage=usage, + reasoning_content=reasoning_content, ) def get_default_model(self) -> str: From eb2fbf80dac6a6ea0f21bd8257bea431fffc16e0 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 19:31:25 +0000 Subject: [PATCH 24/58] fix: use config key to detect provider, prevent api_base misidentifying as vLLM --- README.md | 2 +- nanobot/cli/commands.py | 1 + nanobot/config/schema.py | 35 ++++++++++++++------- nanobot/providers/litellm_provider.py | 45 +++++++++++++-------------- nanobot/providers/registry.py | 33 +++++++++++++++----- 5 files changed, 72 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index d3dcaf7..cb2c64a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,437 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,448 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c90ecde..59ed9e1 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -263,6 +263,7 @@ def _make_provider(config): api_base=config.get_api_base(), default_model=model, extra_headers=p.extra_headers if p else None, + provider_name=config.get_provider_name(), ) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ea2f1c1..edea307 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -134,8 +134,8 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def get_provider(self, model: str | None = None) -> ProviderConfig | None: - """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available.""" + def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + """Match provider config and its registry name. Returns (config, spec_name).""" from nanobot.providers.registry import PROVIDERS model_lower = (model or self.agents.defaults.model).lower() @@ -143,14 +143,24 @@ class Config(BaseSettings): for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and any(kw in model_lower for kw in spec.keywords) and p.api_key: - return p + return p, spec.name # Fallback: gateways first, then others (follows registry order) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) if p and p.api_key: - return p - return None + return p, spec.name + return None, None + + def get_provider(self, model: str | None = None) -> ProviderConfig | None: + """Get matched provider config (api_key, api_base, extra_headers). Falls back to first available.""" + p, _ = self._match_provider(model) + return p + + def get_provider_name(self, model: str | None = None) -> str | None: + """Get the registry name of the matched provider (e.g. "deepseek", "openrouter").""" + _, name = self._match_provider(model) + return name def get_api_key(self, model: str | None = None) -> str | None: """Get API key for the given model. Falls back to first available key.""" @@ -159,15 +169,16 @@ class Config(BaseSettings): def get_api_base(self, model: str | None = None) -> str | None: """Get API base URL for the given model. Applies default URLs for known gateways.""" - from nanobot.providers.registry import PROVIDERS - p = self.get_provider(model) + from nanobot.providers.registry import find_by_name + p, name = self._match_provider(model) if p and p.api_base: return p.api_base - # Only gateways get a default URL here. Standard providers (like Moonshot) - # handle their base URL via env vars in _setup_env, NOT via api_base โ€” - # otherwise find_gateway() would misdetect them as local/vLLM. - for spec in PROVIDERS: - if spec.is_gateway and spec.default_api_base and p == getattr(self.providers, spec.name, None): + # Only gateways get a default api_base here. Standard providers + # (like Moonshot) set their base URL via env vars in _setup_env + # to avoid polluting the global litellm.api_base. + if name: + spec = find_by_name(name) + if spec and spec.is_gateway and spec.default_api_base: return spec.default_api_base return None diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 621a71d..33c300a 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -26,18 +26,16 @@ class LiteLLMProvider(LLMProvider): api_base: str | None = None, default_model: str = "anthropic/claude-opus-4-5", extra_headers: dict[str, str] | None = None, + provider_name: str | None = None, ): super().__init__(api_key, api_base) self.default_model = default_model self.extra_headers = extra_headers or {} - # Detect gateway / local deployment from api_key and api_base - self._gateway = find_gateway(api_key, api_base) - - # Backwards-compatible flags (used by tests and possibly external code) - self.is_openrouter = bool(self._gateway and self._gateway.name == "openrouter") - self.is_aihubmix = bool(self._gateway and self._gateway.name == "aihubmix") - self.is_vllm = bool(self._gateway and self._gateway.is_local) + # Detect gateway / local deployment. + # provider_name (from config key) is the primary signal; + # api_key / api_base are fallback for auto-detection. + self._gateway = find_gateway(provider_name, api_key, api_base) # Configure environment variables if api_key: @@ -51,23 +49,24 @@ class LiteLLMProvider(LLMProvider): def _setup_env(self, api_key: str, api_base: str | None, model: str) -> None: """Set environment variables based on detected provider.""" - if self._gateway: - # Gateway / local: direct set (not setdefault) - os.environ[self._gateway.env_key] = api_key + spec = self._gateway or find_by_model(model) + if not spec: return - - # Standard provider: match by model name - spec = find_by_model(model) - if spec: + + # Gateway/local overrides existing env; standard provider doesn't + if self._gateway: + os.environ[spec.env_key] = api_key + else: os.environ.setdefault(spec.env_key, api_key) - # Resolve env_extras placeholders: - # {api_key} โ†’ user's API key - # {api_base} โ†’ user's api_base, falling back to spec.default_api_base - effective_base = api_base or spec.default_api_base - for env_name, env_val in spec.env_extras: - resolved = env_val.replace("{api_key}", api_key) - resolved = resolved.replace("{api_base}", effective_base) - os.environ.setdefault(env_name, resolved) + + # Resolve env_extras placeholders: + # {api_key} โ†’ user's API key + # {api_base} โ†’ user's api_base, falling back to spec.default_api_base + effective_base = api_base or spec.default_api_base + for env_name, env_val in spec.env_extras: + resolved = env_val.replace("{api_key}", api_key) + resolved = resolved.replace("{api_base}", effective_base) + os.environ.setdefault(env_name, resolved) def _resolve_model(self, model: str) -> str: """Resolve model name by applying provider/gateway prefixes.""" @@ -131,7 +130,7 @@ class LiteLLMProvider(LLMProvider): # Apply model-specific overrides (e.g. kimi-k2.5 temperature) self._apply_model_overrides(model, kwargs) - # Pass api_base directly for custom endpoints (vLLM, etc.) + # Pass api_base for custom endpoints if self.api_base: kwargs["api_base"] = self.api_base diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index aa4a76e..57db4dd 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -241,11 +241,10 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( ), ), - # === Local deployment (fallback: unknown api_base โ†’ assume local) ====== + # === Local deployment (matched by config key, NOT by api_base) ========= # vLLM / any OpenAI-compatible local server. - # If api_base is set but doesn't match a known gateway, we land here. - # Placed before Groq so vLLM wins the fallback when both are configured. + # Detected when config key is "vllm" (provider_name="vllm"). ProviderSpec( name="vllm", keywords=("vllm",), @@ -302,16 +301,34 @@ def find_by_model(model: str) -> ProviderSpec | None: return None -def find_gateway(api_key: str | None, api_base: str | None) -> ProviderSpec | None: - """Detect gateway/local by api_key prefix or api_base substring. - Fallback: unknown api_base โ†’ treat as local (vLLM).""" +def find_gateway( + provider_name: str | None = None, + api_key: str | None = None, + api_base: str | None = None, +) -> ProviderSpec | None: + """Detect gateway/local provider. + + Priority: + 1. provider_name โ€” if it maps to a gateway/local spec, use it directly. + 2. api_key prefix โ€” e.g. "sk-or-" โ†’ OpenRouter. + 3. api_base keyword โ€” e.g. "aihubmix" in URL โ†’ AiHubMix. + + A standard provider with a custom api_base (e.g. DeepSeek behind a proxy) + will NOT be mistaken for vLLM โ€” the old fallback is gone. + """ + # 1. Direct match by config key + if provider_name: + spec = find_by_name(provider_name) + if spec and (spec.is_gateway or spec.is_local): + return spec + + # 2. Auto-detect by api_key prefix / api_base keyword for spec in PROVIDERS: if spec.detect_by_key_prefix and api_key and api_key.startswith(spec.detect_by_key_prefix): return spec if spec.detect_by_base_keyword and api_base and spec.detect_by_base_keyword in api_base: return spec - if api_base: - return next((s for s in PROVIDERS if s.is_local), None) + return None From 25e17717c20cd67d52e65f846efa7fc788c1bfc8 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Feb 2026 19:36:53 +0000 Subject: [PATCH 25/58] fix: restore terminal state on Ctrl+C exit in agent interactive mode --- nanobot/cli/commands.py | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 59ed9e1..fed9bbe 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -3,6 +3,7 @@ import asyncio import atexit import os +import signal from pathlib import Path import select import sys @@ -29,6 +30,7 @@ _READLINE = None _HISTORY_FILE: Path | None = None _HISTORY_HOOK_REGISTERED = False _USING_LIBEDIT = False +_SAVED_TERM_ATTRS = None # original termios settings, restored on exit def _flush_pending_tty_input() -> None: @@ -67,9 +69,27 @@ def _save_history() -> None: return +def _restore_terminal() -> None: + """Restore terminal to its original state (echo, line buffering, etc.).""" + if _SAVED_TERM_ATTRS is None: + return + try: + import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) + except Exception: + pass + + def _enable_line_editing() -> None: """Enable readline for arrow keys, line editing, and persistent history.""" - global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT + global _READLINE, _HISTORY_FILE, _HISTORY_HOOK_REGISTERED, _USING_LIBEDIT, _SAVED_TERM_ATTRS + + # Save terminal state before readline touches it + try: + import termios + _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) + except Exception: + pass history_file = Path.home() / ".nanobot" / "history" / "cli_history" history_file.parent.mkdir(parents=True, exist_ok=True) @@ -421,6 +441,16 @@ def agent( # Interactive mode _enable_line_editing() console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n") + + # input() runs in a worker thread that can't be cancelled. + # Without this handler, asyncio.run() would hang waiting for it. + def _exit_on_sigint(signum, frame): + _save_history() + _restore_terminal() + console.print("\nGoodbye!") + os._exit(0) + + signal.signal(signal.SIGINT, _exit_on_sigint) async def run_interactive(): while True: @@ -433,6 +463,8 @@ def agent( response = await agent_loop.process_direct(user_input, session_id) console.print(f"\n{__logo__} {response}\n") except KeyboardInterrupt: + _save_history() + _restore_terminal() console.print("\nGoodbye!") break From 0a2d557268c98bc5d9290aabbd8b0604b4e0d717 Mon Sep 17 00:00:00 2001 From: Chris Alexander <2815297+chris-alexander@users.noreply.github.com> Date: Sun, 8 Feb 2026 20:50:31 +0000 Subject: [PATCH 26/58] Improve agent CLI chat UX with markdown output and clearer interaction feedback --- nanobot/cli/commands.py | 268 ++++++++++++++++++++++++---------------- 1 file changed, 161 insertions(+), 107 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index fed9bbe..4ae2132 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -10,7 +10,10 @@ import sys import typer from rich.console import Console +from rich.markdown import Markdown +from rich.panel import Panel from rich.table import Table +from rich.text import Text from nanobot import __version__, __logo__ @@ -21,6 +24,30 @@ app = typer.Typer( ) console = Console() +EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"} + + +def _print_agent_response(response: str, render_markdown: bool) -> None: + """Render assistant response with consistent terminal styling.""" + content = response or "" + body = Markdown(content) if render_markdown else Text(content) + console.print() + console.print( + Panel( + body, + title=f"{__logo__} Nanobot", + title_align="left", + border_style="cyan", + padding=(0, 1), + ) + ) + console.print() + + +def _is_exit_command(command: str) -> bool: + """Return True when input should end interactive chat.""" + return command.lower() in EXIT_COMMANDS + # --------------------------------------------------------------------------- # Lightweight CLI input: readline for arrow keys / history, termios for flush @@ -44,6 +71,7 @@ def _flush_pending_tty_input() -> None: try: import termios + termios.tcflush(fd, termios.TCIFLUSH) return except Exception: @@ -75,6 +103,7 @@ def _restore_terminal() -> None: return try: import termios + termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) except Exception: pass @@ -87,6 +116,7 @@ def _enable_line_editing() -> None: # Save terminal state before readline touches it try: import termios + _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) except Exception: pass @@ -148,9 +178,7 @@ def version_callback(value: bool): @app.callback() def main( - version: bool = typer.Option( - None, "--version", "-v", callback=version_callback, is_eager=True - ), + version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True), ): """nanobot - Personal AI Assistant.""" pass @@ -167,34 +195,34 @@ def onboard(): from nanobot.config.loader import get_config_path, save_config from nanobot.config.schema import Config from nanobot.utils.helpers import get_workspace_path - + config_path = get_config_path() - + if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") if not typer.confirm("Overwrite?"): raise typer.Exit() - + # Create default config config = Config() save_config(config) console.print(f"[green]โœ“[/green] Created config at {config_path}") - + # Create workspace workspace = get_workspace_path() console.print(f"[green]โœ“[/green] Created workspace at {workspace}") - + # Create default bootstrap files _create_workspace_templates(workspace) - + console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") - console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") - console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") - - + console.print(' 2. Chat: [cyan]nanobot agent -m "Hello!"[/cyan]') + console.print( + "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]" + ) def _create_workspace_templates(workspace: Path): @@ -238,13 +266,13 @@ Information about the user goes here. - Language: (your preferred language) """, } - + for filename, content in templates.items(): file_path = workspace / filename if not file_path.exists(): file_path.write_text(content) console.print(f" [dim]Created {filename}[/dim]") - + # Create memory directory and MEMORY.md memory_dir = workspace / "memory" memory_dir.mkdir(exist_ok=True) @@ -272,6 +300,7 @@ This file stores important information that should persist across sessions. def _make_provider(config): """Create LiteLLMProvider from config. Exits if no API key found.""" from nanobot.providers.litellm_provider import LiteLLMProvider + p = config.get_provider() model = config.agents.defaults.model if not (p and p.api_key) and not model.startswith("bedrock/"): @@ -306,22 +335,23 @@ def gateway( from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService - + if verbose: import logging + logging.basicConfig(level=logging.DEBUG) - + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - + config = load_config() bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - + # Create cron service first (callback set after agent creation) cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) - + # Create agent with cron service agent = AgentLoop( bus=bus, @@ -335,7 +365,7 @@ def gateway( restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, ) - + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" @@ -347,40 +377,44 @@ def gateway( ) if job.payload.deliver and job.payload.to: from nanobot.bus.events import OutboundMessage - await bus.publish_outbound(OutboundMessage( - channel=job.payload.channel or "cli", - chat_id=job.payload.to, - content=response or "" - )) + + await bus.publish_outbound( + OutboundMessage( + channel=job.payload.channel or "cli", + chat_id=job.payload.to, + content=response or "", + ) + ) return response + cron.on_job = on_cron_job - + # Create heartbeat service async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" return await agent.process_direct(prompt, session_key="heartbeat") - + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, interval_s=30 * 60, # 30 minutes - enabled=True + enabled=True, ) - + # Create channel manager channels = ChannelManager(config, bus, session_manager=session_manager) - + if channels.enabled_channels: console.print(f"[green]โœ“[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") - + cron_status = cron.status() if cron_status["jobs"] > 0: console.print(f"[green]โœ“[/green] Cron: {cron_status['jobs']} scheduled jobs") - + console.print(f"[green]โœ“[/green] Heartbeat: every 30m") - + async def run(): try: await cron.start() @@ -395,12 +429,10 @@ def gateway( cron.stop() agent.stop() await channels.stop_all() - + asyncio.run(run()) - - # ============================================================================ # Agent Commands # ============================================================================ @@ -410,17 +442,29 @@ def gateway( def agent( message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"), + markdown: bool = typer.Option( + True, "--markdown/--no-markdown", help="Render assistant output as Markdown" + ), + logs: bool = typer.Option( + False, "--logs/--no-logs", help="Show nanobot runtime logs during chat" + ), ): """Interact with the agent directly.""" from nanobot.config.loader import load_config from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop - + from loguru import logger + config = load_config() - + bus = MessageBus() provider = _make_provider(config) - + + if logs: + logger.enable("nanobot") + else: + logger.disable("nanobot") + agent_loop = AgentLoop( bus=bus, provider=provider, @@ -429,13 +473,14 @@ def agent( exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, ) - + if message: # Single message mode async def run_once(): - response = await agent_loop.process_direct(message, session_id) - console.print(f"\n{__logo__} {response}") - + with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"): + response = await agent_loop.process_direct(message, session_id) + _print_agent_response(response, render_markdown=markdown) + asyncio.run(run_once()) else: # Interactive mode @@ -451,23 +496,32 @@ def agent( os._exit(0) signal.signal(signal.SIGINT, _exit_on_sigint) - + async def run_interactive(): while True: try: _flush_pending_tty_input() user_input = await _read_interactive_input_async() - if not user_input.strip(): + command = user_input.strip() + if not command: continue - - response = await agent_loop.process_direct(user_input, session_id) - console.print(f"\n{__logo__} {response}\n") + + if _is_exit_command(command): + console.print("\nGoodbye!") + break + + with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"): + response = await agent_loop.process_direct(user_input, session_id) + _print_agent_response(response, render_markdown=markdown) except KeyboardInterrupt: _save_history() _restore_terminal() console.print("\nGoodbye!") break - + except EOFError: + console.print("\nGoodbye!") + break + asyncio.run(run_interactive()) @@ -494,27 +548,15 @@ def channels_status(): # WhatsApp wa = config.channels.whatsapp - table.add_row( - "WhatsApp", - "โœ“" if wa.enabled else "โœ—", - wa.bridge_url - ) + 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 - ) - + table.add_row("Discord", "โœ“" if dc.enabled else "โœ—", dc.gateway_url) + # 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 - ) + table.add_row("Telegram", "โœ“" if tg.enabled else "โœ—", tg_config) console.print(table) @@ -523,57 +565,57 @@ def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" import shutil import subprocess - + # User's bridge location user_bridge = Path.home() / ".nanobot" / "bridge" - + # Check if already built if (user_bridge / "dist" / "index.js").exists(): return user_bridge - + # Check for npm if not shutil.which("npm"): console.print("[red]npm not found. Please install Node.js >= 18.[/red]") raise typer.Exit(1) - + # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - + source = None if (pkg_bridge / "package.json").exists(): source = pkg_bridge elif (src_bridge / "package.json").exists(): source = src_bridge - + if not source: console.print("[red]Bridge source not found.[/red]") console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) - + console.print(f"{__logo__} Setting up bridge...") - + # Copy to user directory user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): shutil.rmtree(user_bridge) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - + # Install and build try: console.print(" Installing dependencies...") subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - + console.print(" Building...") subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - + console.print("[green]โœ“[/green] Bridge ready\n") except subprocess.CalledProcessError as e: console.print(f"[red]Build failed: {e}[/red]") if e.stderr: console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") raise typer.Exit(1) - + return user_bridge @@ -581,12 +623,12 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess - + bridge_dir = _get_bridge_dir() - + console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") - + try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) except subprocess.CalledProcessError as e: @@ -610,24 +652,25 @@ def cron_list( """List scheduled jobs.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + jobs = service.list_jobs(include_disabled=all) - + if not jobs: console.print("No scheduled jobs.") return - + table = Table(title="Scheduled Jobs") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Schedule") table.add_column("Status") table.add_column("Next Run") - + import time + for job in jobs: # Format schedule if job.schedule.kind == "every": @@ -636,17 +679,19 @@ def cron_list( sched = job.schedule.expr or "" else: sched = "one-time" - + # Format next run next_run = "" if job.state.next_run_at_ms: - next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) + next_time = time.strftime( + "%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000) + ) next_run = next_time - + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - + table.add_row(job.id, job.name, sched, status, next_run) - + console.print(table) @@ -659,13 +704,15 @@ def cron_add( at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), to: str = typer.Option(None, "--to", help="Recipient for delivery"), - channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), + channel: str = typer.Option( + None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')" + ), ): """Add a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule - + # Determine schedule type if every: schedule = CronSchedule(kind="every", every_ms=every * 1000) @@ -673,15 +720,16 @@ def cron_add( schedule = CronSchedule(kind="cron", expr=cron_expr) elif at: import datetime + dt = datetime.datetime.fromisoformat(at) schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) else: console.print("[red]Error: Must specify --every, --cron, or --at[/red]") raise typer.Exit(1) - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.add_job( name=name, schedule=schedule, @@ -690,7 +738,7 @@ def cron_add( to=to, channel=channel, ) - + console.print(f"[green]โœ“[/green] Added job '{job.name}' ({job.id})") @@ -701,10 +749,10 @@ def cron_remove( """Remove a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + if service.remove_job(job_id): console.print(f"[green]โœ“[/green] Removed job {job_id}") else: @@ -719,10 +767,10 @@ def cron_enable( """Enable or disable a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.enable_job(job_id, enabled=not disable) if job: status = "disabled" if disable else "enabled" @@ -739,13 +787,13 @@ def cron_run( """Manually run a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + async def run(): return await service.run_job(job_id, force=force) - + if asyncio.run(run()): console.print(f"[green]โœ“[/green] Job executed") else: @@ -768,14 +816,18 @@ def status(): console.print(f"{__logo__} nanobot Status\n") - console.print(f"Config: {config_path} {'[green]โœ“[/green]' if config_path.exists() else '[red]โœ—[/red]'}") - console.print(f"Workspace: {workspace} {'[green]โœ“[/green]' if workspace.exists() else '[red]โœ—[/red]'}") + console.print( + f"Config: {config_path} {'[green]โœ“[/green]' if config_path.exists() else '[red]โœ—[/red]'}" + ) + console.print( + f"Workspace: {workspace} {'[green]โœ“[/green]' if workspace.exists() else '[red]โœ—[/red]'}" + ) if config_path.exists(): from nanobot.providers.registry import PROVIDERS console.print(f"Model: {config.agents.defaults.model}") - + # Check API keys from registry for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) @@ -789,7 +841,9 @@ def status(): console.print(f"{spec.label}: [dim]not set[/dim]") else: has_key = bool(p.api_key) - console.print(f"{spec.label}: {'[green]โœ“[/green]' if has_key else '[dim]not set[/dim]'}") + console.print( + f"{spec.label}: {'[green]โœ“[/green]' if has_key else '[dim]not set[/dim]'}" + ) if __name__ == "__main__": From 9c6ffa0d562de1ba7e776ffbe352be637c6ebdc1 Mon Sep 17 00:00:00 2001 From: Chris Alexander <2815297+chris-alexander@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:07:02 +0000 Subject: [PATCH 27/58] Trim CLI patch to remove unrelated whitespace churn --- nanobot/cli/commands.py | 262 +++++++++++++++++++--------------------- 1 file changed, 126 insertions(+), 136 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 4ae2132..875eb90 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -26,29 +26,6 @@ app = typer.Typer( console = Console() EXIT_COMMANDS = {"exit", "quit", "/exit", "/quit", ":q"} - -def _print_agent_response(response: str, render_markdown: bool) -> None: - """Render assistant response with consistent terminal styling.""" - content = response or "" - body = Markdown(content) if render_markdown else Text(content) - console.print() - console.print( - Panel( - body, - title=f"{__logo__} Nanobot", - title_align="left", - border_style="cyan", - padding=(0, 1), - ) - ) - console.print() - - -def _is_exit_command(command: str) -> bool: - """Return True when input should end interactive chat.""" - return command.lower() in EXIT_COMMANDS - - # --------------------------------------------------------------------------- # Lightweight CLI input: readline for arrow keys / history, termios for flush # --------------------------------------------------------------------------- @@ -71,7 +48,6 @@ def _flush_pending_tty_input() -> None: try: import termios - termios.tcflush(fd, termios.TCIFLUSH) return except Exception: @@ -103,7 +79,6 @@ def _restore_terminal() -> None: return try: import termios - termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, _SAVED_TERM_ATTRS) except Exception: pass @@ -116,7 +91,6 @@ def _enable_line_editing() -> None: # Save terminal state before readline touches it try: import termios - _SAVED_TERM_ATTRS = termios.tcgetattr(sys.stdin.fileno()) except Exception: pass @@ -162,6 +136,28 @@ def _prompt_text() -> str: return "\001\033[1;34m\002You:\001\033[0m\002 " +def _print_agent_response(response: str, render_markdown: bool) -> None: + """Render assistant response with consistent terminal styling.""" + content = response or "" + body = Markdown(content) if render_markdown else Text(content) + console.print() + console.print( + Panel( + body, + title=f"{__logo__} Nanobot", + title_align="left", + border_style="cyan", + padding=(0, 1), + ) + ) + console.print() + + +def _is_exit_command(command: str) -> bool: + """Return True when input should end interactive chat.""" + return command.lower() in EXIT_COMMANDS + + async def _read_interactive_input_async() -> str: """Read user input with arrow keys and history (runs input() in a thread).""" try: @@ -178,7 +174,9 @@ def version_callback(value: bool): @app.callback() def main( - version: bool = typer.Option(None, "--version", "-v", callback=version_callback, is_eager=True), + version: bool = typer.Option( + None, "--version", "-v", callback=version_callback, is_eager=True + ), ): """nanobot - Personal AI Assistant.""" pass @@ -195,34 +193,34 @@ def onboard(): from nanobot.config.loader import get_config_path, save_config from nanobot.config.schema import Config from nanobot.utils.helpers import get_workspace_path - + config_path = get_config_path() - + if config_path.exists(): console.print(f"[yellow]Config already exists at {config_path}[/yellow]") if not typer.confirm("Overwrite?"): raise typer.Exit() - + # Create default config config = Config() save_config(config) console.print(f"[green]โœ“[/green] Created config at {config_path}") - + # Create workspace workspace = get_workspace_path() console.print(f"[green]โœ“[/green] Created workspace at {workspace}") - + # Create default bootstrap files _create_workspace_templates(workspace) - + console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") console.print(" 1. Add your API key to [cyan]~/.nanobot/config.json[/cyan]") console.print(" Get one at: https://openrouter.ai/keys") - console.print(' 2. Chat: [cyan]nanobot agent -m "Hello!"[/cyan]') - console.print( - "\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]" - ) + console.print(" 2. Chat: [cyan]nanobot agent -m \"Hello!\"[/cyan]") + console.print("\n[dim]Want Telegram/WhatsApp? See: https://github.com/HKUDS/nanobot#-chat-apps[/dim]") + + def _create_workspace_templates(workspace: Path): @@ -266,13 +264,13 @@ Information about the user goes here. - Language: (your preferred language) """, } - + for filename, content in templates.items(): file_path = workspace / filename if not file_path.exists(): file_path.write_text(content) console.print(f" [dim]Created {filename}[/dim]") - + # Create memory directory and MEMORY.md memory_dir = workspace / "memory" memory_dir.mkdir(exist_ok=True) @@ -300,7 +298,6 @@ This file stores important information that should persist across sessions. def _make_provider(config): """Create LiteLLMProvider from config. Exits if no API key found.""" from nanobot.providers.litellm_provider import LiteLLMProvider - p = config.get_provider() model = config.agents.defaults.model if not (p and p.api_key) and not model.startswith("bedrock/"): @@ -335,23 +332,22 @@ def gateway( from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService - + if verbose: import logging - logging.basicConfig(level=logging.DEBUG) - + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - + config = load_config() bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) - + # Create cron service first (callback set after agent creation) cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) - + # Create agent with cron service agent = AgentLoop( bus=bus, @@ -365,7 +361,7 @@ def gateway( restrict_to_workspace=config.tools.restrict_to_workspace, session_manager=session_manager, ) - + # Set cron callback (needs agent) async def on_cron_job(job: CronJob) -> str | None: """Execute a cron job through the agent.""" @@ -377,44 +373,40 @@ def gateway( ) if job.payload.deliver and job.payload.to: from nanobot.bus.events import OutboundMessage - - await bus.publish_outbound( - OutboundMessage( - channel=job.payload.channel or "cli", - chat_id=job.payload.to, - content=response or "", - ) - ) + await bus.publish_outbound(OutboundMessage( + channel=job.payload.channel or "cli", + chat_id=job.payload.to, + content=response or "" + )) return response - cron.on_job = on_cron_job - + # Create heartbeat service async def on_heartbeat(prompt: str) -> str: """Execute heartbeat through the agent.""" return await agent.process_direct(prompt, session_key="heartbeat") - + heartbeat = HeartbeatService( workspace=config.workspace_path, on_heartbeat=on_heartbeat, interval_s=30 * 60, # 30 minutes - enabled=True, + enabled=True ) - + # Create channel manager channels = ChannelManager(config, bus, session_manager=session_manager) - + if channels.enabled_channels: console.print(f"[green]โœ“[/green] Channels enabled: {', '.join(channels.enabled_channels)}") else: console.print("[yellow]Warning: No channels enabled[/yellow]") - + cron_status = cron.status() if cron_status["jobs"] > 0: console.print(f"[green]โœ“[/green] Cron: {cron_status['jobs']} scheduled jobs") - + console.print(f"[green]โœ“[/green] Heartbeat: every 30m") - + async def run(): try: await cron.start() @@ -429,10 +421,12 @@ def gateway( cron.stop() agent.stop() await channels.stop_all() - + asyncio.run(run()) + + # ============================================================================ # Agent Commands # ============================================================================ @@ -442,21 +436,17 @@ def gateway( def agent( message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"), session_id: str = typer.Option("cli:default", "--session", "-s", help="Session ID"), - markdown: bool = typer.Option( - True, "--markdown/--no-markdown", help="Render assistant output as Markdown" - ), - logs: bool = typer.Option( - False, "--logs/--no-logs", help="Show nanobot runtime logs during chat" - ), + markdown: bool = typer.Option(True, "--markdown/--no-markdown", help="Render assistant output as Markdown"), + logs: bool = typer.Option(False, "--logs/--no-logs", help="Show nanobot runtime logs during chat"), ): """Interact with the agent directly.""" from nanobot.config.loader import load_config from nanobot.bus.queue import MessageBus from nanobot.agent.loop import AgentLoop from loguru import logger - + config = load_config() - + bus = MessageBus() provider = _make_provider(config) @@ -464,7 +454,7 @@ def agent( logger.enable("nanobot") else: logger.disable("nanobot") - + agent_loop = AgentLoop( bus=bus, provider=provider, @@ -473,14 +463,14 @@ def agent( exec_config=config.tools.exec, restrict_to_workspace=config.tools.restrict_to_workspace, ) - + if message: # Single message mode async def run_once(): with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"): response = await agent_loop.process_direct(message, session_id) _print_agent_response(response, render_markdown=markdown) - + asyncio.run(run_once()) else: # Interactive mode @@ -496,7 +486,7 @@ def agent( os._exit(0) signal.signal(signal.SIGINT, _exit_on_sigint) - + async def run_interactive(): while True: try: @@ -509,7 +499,7 @@ def agent( if _is_exit_command(command): console.print("\nGoodbye!") break - + with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"): response = await agent_loop.process_direct(user_input, session_id) _print_agent_response(response, render_markdown=markdown) @@ -521,7 +511,7 @@ def agent( except EOFError: console.print("\nGoodbye!") break - + asyncio.run(run_interactive()) @@ -548,15 +538,27 @@ def channels_status(): # WhatsApp wa = config.channels.whatsapp - table.add_row("WhatsApp", "โœ“" if wa.enabled else "โœ—", wa.bridge_url) + 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) - + table.add_row( + "Discord", + "โœ“" if dc.enabled else "โœ—", + dc.gateway_url + ) + # 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) + table.add_row( + "Telegram", + "โœ“" if tg.enabled else "โœ—", + tg_config + ) console.print(table) @@ -565,57 +567,57 @@ def _get_bridge_dir() -> Path: """Get the bridge directory, setting it up if needed.""" import shutil import subprocess - + # User's bridge location user_bridge = Path.home() / ".nanobot" / "bridge" - + # Check if already built if (user_bridge / "dist" / "index.js").exists(): return user_bridge - + # Check for npm if not shutil.which("npm"): console.print("[red]npm not found. Please install Node.js >= 18.[/red]") raise typer.Exit(1) - + # Find source bridge: first check package data, then source dir pkg_bridge = Path(__file__).parent.parent / "bridge" # nanobot/bridge (installed) src_bridge = Path(__file__).parent.parent.parent / "bridge" # repo root/bridge (dev) - + source = None if (pkg_bridge / "package.json").exists(): source = pkg_bridge elif (src_bridge / "package.json").exists(): source = src_bridge - + if not source: console.print("[red]Bridge source not found.[/red]") console.print("Try reinstalling: pip install --force-reinstall nanobot") raise typer.Exit(1) - + console.print(f"{__logo__} Setting up bridge...") - + # Copy to user directory user_bridge.parent.mkdir(parents=True, exist_ok=True) if user_bridge.exists(): shutil.rmtree(user_bridge) shutil.copytree(source, user_bridge, ignore=shutil.ignore_patterns("node_modules", "dist")) - + # Install and build try: console.print(" Installing dependencies...") subprocess.run(["npm", "install"], cwd=user_bridge, check=True, capture_output=True) - + console.print(" Building...") subprocess.run(["npm", "run", "build"], cwd=user_bridge, check=True, capture_output=True) - + console.print("[green]โœ“[/green] Bridge ready\n") except subprocess.CalledProcessError as e: console.print(f"[red]Build failed: {e}[/red]") if e.stderr: console.print(f"[dim]{e.stderr.decode()[:500]}[/dim]") raise typer.Exit(1) - + return user_bridge @@ -623,12 +625,12 @@ def _get_bridge_dir() -> Path: def channels_login(): """Link device via QR code.""" import subprocess - + bridge_dir = _get_bridge_dir() - + console.print(f"{__logo__} Starting bridge...") console.print("Scan the QR code to connect.\n") - + try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True) except subprocess.CalledProcessError as e: @@ -652,25 +654,24 @@ def cron_list( """List scheduled jobs.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + jobs = service.list_jobs(include_disabled=all) - + if not jobs: console.print("No scheduled jobs.") return - + table = Table(title="Scheduled Jobs") table.add_column("ID", style="cyan") table.add_column("Name") table.add_column("Schedule") table.add_column("Status") table.add_column("Next Run") - + import time - for job in jobs: # Format schedule if job.schedule.kind == "every": @@ -679,19 +680,17 @@ def cron_list( sched = job.schedule.expr or "" else: sched = "one-time" - + # Format next run next_run = "" if job.state.next_run_at_ms: - next_time = time.strftime( - "%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000) - ) + next_time = time.strftime("%Y-%m-%d %H:%M", time.localtime(job.state.next_run_at_ms / 1000)) next_run = next_time - + status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" - + table.add_row(job.id, job.name, sched, status, next_run) - + console.print(table) @@ -704,15 +703,13 @@ def cron_add( at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"), deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"), to: str = typer.Option(None, "--to", help="Recipient for delivery"), - channel: str = typer.Option( - None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')" - ), + channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"), ): """Add a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule - + # Determine schedule type if every: schedule = CronSchedule(kind="every", every_ms=every * 1000) @@ -720,16 +717,15 @@ def cron_add( schedule = CronSchedule(kind="cron", expr=cron_expr) elif at: import datetime - dt = datetime.datetime.fromisoformat(at) schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000)) else: console.print("[red]Error: Must specify --every, --cron, or --at[/red]") raise typer.Exit(1) - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.add_job( name=name, schedule=schedule, @@ -738,7 +734,7 @@ def cron_add( to=to, channel=channel, ) - + console.print(f"[green]โœ“[/green] Added job '{job.name}' ({job.id})") @@ -749,10 +745,10 @@ def cron_remove( """Remove a scheduled job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + if service.remove_job(job_id): console.print(f"[green]โœ“[/green] Removed job {job_id}") else: @@ -767,10 +763,10 @@ def cron_enable( """Enable or disable a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + job = service.enable_job(job_id, enabled=not disable) if job: status = "disabled" if disable else "enabled" @@ -787,13 +783,13 @@ def cron_run( """Manually run a job.""" from nanobot.config.loader import get_data_dir from nanobot.cron.service import CronService - + store_path = get_data_dir() / "cron" / "jobs.json" service = CronService(store_path) - + async def run(): return await service.run_job(job_id, force=force) - + if asyncio.run(run()): console.print(f"[green]โœ“[/green] Job executed") else: @@ -816,18 +812,14 @@ def status(): console.print(f"{__logo__} nanobot Status\n") - console.print( - f"Config: {config_path} {'[green]โœ“[/green]' if config_path.exists() else '[red]โœ—[/red]'}" - ) - console.print( - f"Workspace: {workspace} {'[green]โœ“[/green]' if workspace.exists() else '[red]โœ—[/red]'}" - ) + console.print(f"Config: {config_path} {'[green]โœ“[/green]' if config_path.exists() else '[red]โœ—[/red]'}") + console.print(f"Workspace: {workspace} {'[green]โœ“[/green]' if workspace.exists() else '[red]โœ—[/red]'}") if config_path.exists(): from nanobot.providers.registry import PROVIDERS console.print(f"Model: {config.agents.defaults.model}") - + # Check API keys from registry for spec in PROVIDERS: p = getattr(config.providers, spec.name, None) @@ -841,9 +833,7 @@ def status(): console.print(f"{spec.label}: [dim]not set[/dim]") else: has_key = bool(p.api_key) - console.print( - f"{spec.label}: {'[green]โœ“[/green]' if has_key else '[dim]not set[/dim]'}" - ) + console.print(f"{spec.label}: {'[green]โœ“[/green]' if has_key else '[dim]not set[/dim]'}") if __name__ == "__main__": From 8fda0fcab3a62104176b1b75ce3ce458dad28948 Mon Sep 17 00:00:00 2001 From: Chris Alexander <2815297+chris-alexander@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:51:13 +0000 Subject: [PATCH 28/58] Document agent markdown/log flags and interactive exit commands --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index cb2c64a..5d86820 100644 --- a/README.md +++ b/README.md @@ -458,11 +458,15 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot | `nanobot onboard` | Initialize config & workspace | | `nanobot agent -m "..."` | Chat with the agent | | `nanobot agent` | Interactive chat mode | +| `nanobot agent --no-markdown` | Show plain-text replies | +| `nanobot agent --logs` | Show runtime logs during chat | | `nanobot gateway` | Start the gateway | | `nanobot status` | Show status | | `nanobot channels login` | Link WhatsApp (scan QR) | | `nanobot channels status` | Show channel status | +Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. +
Scheduled Tasks (Cron) From 20ca78c1062ca50fd5e1d3c9acf34ad1c947a1df Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 04:51:58 +0000 Subject: [PATCH 29/58] docs: add Zhipu coding plan apiBase tip --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cb2c64a..9f1e0fd 100644 --- a/README.md +++ b/README.md @@ -378,8 +378,9 @@ Config file: `~/.nanobot/config.json` ### Providers -> [!NOTE] -> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. +> [!TIP] +> - **Groq** provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. +> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. | Provider | Purpose | Get API Key | |----------|---------|-------------| From d47219ef6a094da4aa09318a2051d7262385c48e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 05:15:26 +0000 Subject: [PATCH 30/58] fix: unify exit cleanup, conditionally show spinner with --logs flag --- nanobot/cli/commands.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 875eb90..a1f426e 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -144,7 +144,7 @@ def _print_agent_response(response: str, render_markdown: bool) -> None: console.print( Panel( body, - title=f"{__logo__} Nanobot", + title=f"{__logo__} nanobot", title_align="left", border_style="cyan", padding=(0, 1), @@ -464,10 +464,17 @@ def agent( restrict_to_workspace=config.tools.restrict_to_workspace, ) + # Show spinner when logs are off (no output to miss); skip when logs are on + def _thinking_ctx(): + if logs: + from contextlib import nullcontext + return nullcontext() + return console.status("[dim]nanobot is thinking...[/dim]", spinner="dots") + if message: # Single message mode async def run_once(): - with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"): + with _thinking_ctx(): response = await agent_loop.process_direct(message, session_id) _print_agent_response(response, render_markdown=markdown) @@ -475,7 +482,7 @@ def agent( else: # Interactive mode _enable_line_editing() - console.print(f"{__logo__} Interactive mode (Ctrl+C to exit)\n") + console.print(f"{__logo__} Interactive mode (type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit)\n") # input() runs in a worker thread that can't be cancelled. # Without this handler, asyncio.run() would hang waiting for it. @@ -497,10 +504,12 @@ def agent( continue if _is_exit_command(command): + _save_history() + _restore_terminal() console.print("\nGoodbye!") break - with console.status("[dim]Nanobot is thinking...[/dim]", spinner="dots"): + with _thinking_ctx(): response = await agent_loop.process_direct(user_input, session_id) _print_agent_response(response, render_markdown=markdown) except KeyboardInterrupt: @@ -509,6 +518,8 @@ def agent( console.print("\nGoodbye!") break except EOFError: + _save_history() + _restore_terminal() console.print("\nGoodbye!") break From d223454a9885986b5c5a89c30fb3941b07457e40 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 06:19:35 +0000 Subject: [PATCH 31/58] fix: cap processed UIDs, move email docs into README, remove standalone guide --- EMAIL_ASSISTANT_E2E_GUIDE.md | 164 ----------------------------------- README.md | 57 +++++++++++- nanobot/channels/email.py | 6 +- 3 files changed, 59 insertions(+), 168 deletions(-) delete mode 100644 EMAIL_ASSISTANT_E2E_GUIDE.md diff --git a/EMAIL_ASSISTANT_E2E_GUIDE.md b/EMAIL_ASSISTANT_E2E_GUIDE.md deleted file mode 100644 index a72a18c..0000000 --- a/EMAIL_ASSISTANT_E2E_GUIDE.md +++ /dev/null @@ -1,164 +0,0 @@ -# Nanobot Email Assistant: End-to-End Guide - -This guide explains how to run nanobot as a real email assistant with explicit user permission and optional automatic replies. - -## 1. What This Feature Does - -- Read unread emails via IMAP. -- Let the agent analyze/respond to email content. -- Send replies via SMTP. -- Enforce explicit owner consent before mailbox access. -- Let you toggle automatic replies on or off. - -## 2. Permission Model (Required) - -`channels.email.consentGranted` is the hard permission gate. - -- `false`: nanobot must not access mailbox content and must not send email. -- `true`: nanobot may read/send based on other settings. - -Only set `consentGranted: true` after the mailbox owner explicitly agrees. - -## 3. Auto-Reply Mode - -`channels.email.autoReplyEnabled` controls outbound automatic email replies. - -- `true`: inbound emails can receive automatic agent replies. -- `false`: inbound emails can still be read/processed, but automatic replies are skipped. - -Use `autoReplyEnabled: false` when you want analysis-only mode. - -## 4. Required Account Setup (Gmail Example) - -1. Enable 2-Step Verification in Google account security settings. -2. Create an App Password. -3. Use this app password for both IMAP and SMTP auth. - -Recommended servers: -- IMAP host/port: `imap.gmail.com:993` (SSL) -- SMTP host/port: `smtp.gmail.com:587` (STARTTLS) - -## 5. Config Example - -Edit `~/.nanobot/config.json`: - -```json -{ - "channels": { - "email": { - "enabled": true, - "consentGranted": true, - "imapHost": "imap.gmail.com", - "imapPort": 993, - "imapUsername": "you@gmail.com", - "imapPassword": "${NANOBOT_EMAIL_IMAP_PASSWORD}", - "imapMailbox": "INBOX", - "imapUseSsl": true, - "smtpHost": "smtp.gmail.com", - "smtpPort": 587, - "smtpUsername": "you@gmail.com", - "smtpPassword": "${NANOBOT_EMAIL_SMTP_PASSWORD}", - "smtpUseTls": true, - "smtpUseSsl": false, - "fromAddress": "you@gmail.com", - "autoReplyEnabled": true, - "pollIntervalSeconds": 30, - "markSeen": true, - "allowFrom": ["trusted.sender@example.com"] - } - } -} -``` - -## 6. Set Secrets via Environment Variables - -In the same shell before starting gateway: - -```bash -read -s "NANOBOT_EMAIL_IMAP_PASSWORD?IMAP app password: " -echo -read -s "NANOBOT_EMAIL_SMTP_PASSWORD?SMTP app password: " -echo -export NANOBOT_EMAIL_IMAP_PASSWORD -export NANOBOT_EMAIL_SMTP_PASSWORD -``` - -If you use one app password for both, enter the same value twice. - -## 7. Run and Verify - -Start: - -```bash -cd /Users/kaijimima1234/Desktop/nanobot -PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot gateway -``` - -Check channel status: - -```bash -PYTHONPATH=/Users/kaijimima1234/Desktop/nanobot .venv/bin/nanobot channels status -``` - -Expected behavior: -- `enabled=true + consentGranted=true + autoReplyEnabled=true`: read + auto reply. -- `enabled=true + consentGranted=true + autoReplyEnabled=false`: read only, no auto reply. -- `consentGranted=false`: no read, no send. - -## 8. Commands You Can Tell Nanobot - -Once gateway is running and email consent is enabled: - -1. Summarize yesterday's emails: - -```text -summarize my yesterday email -``` - -or - -```text -!email summary yesterday -``` - -2. Send an email to a friend: - -```text -!email send friend@example.com | Subject here | Body here -``` - -or - -```text -send email to friend@example.com subject: Subject here body: Body here -``` - -Notes: -- Sending command always performs a direct send (manual action by you). -- If `consentGranted` is `false`, send/read are blocked. -- If `autoReplyEnabled` is `false`, automatic replies are disabled, but direct send command above still works. - -## 9. End-to-End Test Plan - -1. Send a test email from an allowed sender to your mailbox. -2. Confirm nanobot receives and processes it. -3. If `autoReplyEnabled=true`, confirm a reply is delivered. -4. Set `autoReplyEnabled=false`, send another test email. -5. Confirm no auto-reply is sent. -6. Set `consentGranted=false`, send another test email. -7. Confirm nanobot does not read/send. - -## 10. Security Notes - -- Never commit real passwords/tokens into git. -- Prefer environment variables for secrets. -- Keep `allowFrom` restricted whenever possible. -- Rotate app passwords immediately if leaked. - -## 11. PR Checklist - -- [ ] `consentGranted` gating works for read/send. -- [ ] `autoReplyEnabled` toggle works as documented. -- [ ] README updated with new fields. -- [ ] Tests pass (`pytest`). -- [ ] No real credentials in tracked files. diff --git a/README.md b/README.md index 502a42f..8f7c1a2 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,448 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News @@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!" ## ๐Ÿ’ฌ Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu โ€” anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email โ€” anytime, anywhere. | Channel | Setup | |---------|-------| @@ -174,6 +174,8 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu โ€” anytime, | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Medium (scan QR) | | **Feishu** | Medium (app credentials) | +| **DingTalk** | Medium (app credentials) | +| **Email** | Medium (IMAP/SMTP credentials) |
Telegram (Recommended) @@ -372,6 +374,55 @@ nanobot gateway
+
+Email + +Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data. + +**1. Get credentials (Gmail example)** +- Enable 2-Step Verification in Google account security +- Create an [App Password](https://myaccount.google.com/apppasswords) +- Use this app password for both IMAP and SMTP + +**2. Configure** + +> [!TIP] +> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. + +```json +{ + "channels": { + "email": { + "enabled": true, + "consentGranted": true, + "imapHost": "imap.gmail.com", + "imapPort": 993, + "imapUsername": "you@gmail.com", + "imapPassword": "your-app-password", + "imapUseSsl": true, + "smtpHost": "smtp.gmail.com", + "smtpPort": 587, + "smtpUsername": "you@gmail.com", + "smtpPassword": "your-app-password", + "smtpUseTls": true, + "fromAddress": "you@gmail.com", + "allowFrom": ["trusted@example.com"] + } + } +} +``` + +> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely. +> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses. + +**3. Run** + +```bash +nanobot gateway +``` + +
+ ## โš™๏ธ Configuration Config file: `~/.nanobot/config.json` @@ -542,7 +593,7 @@ PRs welcome! The codebase is intentionally small and readable. ๐Ÿค— - [ ] **Multi-modal** โ€” See and hear (images, voice, video) - [ ] **Long-term memory** โ€” Never forget important context - [ ] **Better reasoning** โ€” Multi-step planning and reflection -- [ ] **More integrations** โ€” Discord, Slack, email, calendar +- [ ] **More integrations** โ€” Slack, calendar, and more - [ ] **Self-improvement** โ€” Learn from feedback and mistakes ### Contributors diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 029c00d..0e47067 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -55,7 +55,8 @@ class EmailChannel(BaseChannel): self.config: EmailConfig = config self._last_subject_by_chat: dict[str, str] = {} self._last_message_id_by_chat: dict[str, str] = {} - self._processed_uids: set[str] = set() + self._processed_uids: set[str] = set() # Capped to prevent unbounded growth + self._MAX_PROCESSED_UIDS = 100000 async def start(self) -> None: """Start polling IMAP for inbound emails.""" @@ -301,6 +302,9 @@ class EmailChannel(BaseChannel): if dedupe and uid: self._processed_uids.add(uid) + # mark_seen is the primary dedup; this set is a safety net + if len(self._processed_uids) > self._MAX_PROCESSED_UIDS: + self._processed_uids.clear() if mark_seen: client.store(imap_id, "+FLAGS", "\\Seen") From 26c506c413e5ff77e13f49509988a98fa82fdb2a Mon Sep 17 00:00:00 2001 From: JakeRowe19 <117069245+JakeRowe19@users.noreply.github.com> Date: Mon, 9 Feb 2026 09:49:43 +0300 Subject: [PATCH 32/58] Update README.md Fixed unclear note for getting Telegram user id. /issues/74 --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 8f7c1a2..335eae0 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E } ``` -> Get your user ID from `@userinfobot` on Telegram. +> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. +> Copy this value **without the `@` symbol** and paste it into the config file. + **3. Run** From 34dc933fce092d8bd8de8df2c543276d7691156f Mon Sep 17 00:00:00 2001 From: yinwm Date: Mon, 9 Feb 2026 15:47:55 +0800 Subject: [PATCH 33/58] feat: add QQ channel integration with botpy SDK Add official QQ platform support using botpy SDK with WebSocket connection. Features: - C2C (private message) support via QQ Open Platform - WebSocket-based bot connection (no public IP required) - Message deduplication with efficient deque-based LRU cache - User whitelist support via allow_from configuration - Clean async architecture using single event loop Changes: - Add QQChannel implementation in nanobot/channels/qq.py - Add QQConfig schema with appId and secret fields - Register QQ channel in ChannelManager - Update README with QQ setup instructions - Add qq-botpy dependency to pyproject.toml - Add botpy.log to .gitignore Setup: 1. Get AppID and Secret from q.qq.com 2. Configure in ~/.nanobot/config.json: { "channels": { "qq": { "enabled": true, "appId": "YOUR_APP_ID", "secret": "YOUR_APP_SECRET", "allowFrom": [] } } } 3. Run: nanobot gateway Note: Group chat support will be added in future updates. Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 3 +- README.md | 42 ++++++- nanobot/channels/manager.py | 12 ++ nanobot/channels/qq.py | 211 ++++++++++++++++++++++++++++++++++++ nanobot/config/schema.py | 9 ++ pyproject.toml | 1 + 6 files changed, 276 insertions(+), 2 deletions(-) create mode 100644 nanobot/channels/qq.py diff --git a/.gitignore b/.gitignore index 55338f7..4e58574 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ docs/ __pycache__/ poetry.lock .pytest_cache/ -tests/ \ No newline at end of file +tests/ +botpy.log \ No newline at end of file diff --git a/README.md b/README.md index 8f7c1a2..4acaca8 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!" ## ๐Ÿ’ฌ Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email โ€” anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Email, or QQ โ€” anytime, anywhere. | Channel | Setup | |---------|-------| @@ -176,6 +176,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E | **Feishu** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) | | **Email** | Medium (IMAP/SMTP credentials) | +| **QQ** | Easy (app credentials) |
Telegram (Recommended) @@ -335,6 +336,45 @@ nanobot gateway
+
+QQ (QQ็ง่Š) + +Uses **botpy SDK** with WebSocket โ€” no public IP required. + +**1. Create a QQ bot** +- Visit [QQ Open Platform](https://q.qq.com) +- Create a new bot application +- Get **AppID** and **Secret** from "Developer Settings" + +**2. Configure** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "appId": "YOUR_APP_ID", + "secret": "YOUR_APP_SECRET", + "allowFrom": [] + } + } +} +``` + +> `allowFrom`: Leave empty for public access, or add user openids to restrict access. +> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]` + +**3. Run** + +```bash +nanobot gateway +``` + +> [!TIP] +> QQ bot currently supports **private messages only**. Group chat support coming soon! + +
+
DingTalk (้’‰้’‰) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 26fa9f3..a7b1ed5 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -106,6 +106,18 @@ class ChannelManager: logger.info("Email channel enabled") except ImportError as e: logger.warning(f"Email 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(f"QQ channel not available: {e}") async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py new file mode 100644 index 0000000..98ca883 --- /dev/null +++ b/nanobot/channels/qq.py @@ -0,0 +1,211 @@ +"""QQ channel implementation using botpy SDK.""" + +import asyncio +from collections import deque +from typing import TYPE_CHECKING + +from loguru import logger + +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel +from nanobot.config.schema import QQConfig + +try: + import botpy + from botpy.message import C2CMessage + + QQ_AVAILABLE = True +except ImportError: + QQ_AVAILABLE = False + botpy = None + C2CMessage = None + +if TYPE_CHECKING: + from botpy.message import C2CMessage + + +def parse_chat_id(chat_id: str) -> tuple[str, str]: + """Parse chat_id into (channel, user_id). + + Args: + chat_id: Format "channel:user_id", e.g. "qq:openid_xxx" + + Returns: + Tuple of (channel, user_id) + """ + if ":" not in chat_id: + raise ValueError(f"Invalid chat_id format: {chat_id}") + channel, user_id = chat_id.split(":", 1) + return channel, user_id + + +class QQChannel(BaseChannel): + """ + QQ channel using botpy SDK with WebSocket connection. + + Uses botpy SDK to connect to QQ Open Platform (q.qq.com). + + Requires: + - App ID and Secret from q.qq.com + - Robot capability enabled + """ + + name = "qq" + + def __init__(self, config: QQConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: QQConfig = config + self._client: "botpy.Client | None" = None + self._processed_message_ids: deque = deque(maxlen=1000) + self._bot_task: asyncio.Task | None = None + + async def start(self) -> None: + """Start the QQ bot.""" + if not QQ_AVAILABLE: + logger.error("QQ SDK ๆœชๅฎ‰่ฃ…ใ€‚่ฏท่ฟ่กŒ๏ผšpip install qq-botpy") + return + + if not self.config.app_id or not self.config.secret: + logger.error("QQ app_id ๅ’Œ secret ๆœช้…็ฝฎ") + return + + self._running = True + + # Create bot client with C2C intents + intents = botpy.Intents.all() + logger.info(f"QQ Intents ้…็ฝฎๅ€ผ: {intents.value}") + + # Create custom bot class with message handlers + class QQBot(botpy.Client): + def __init__(self, channel): + super().__init__(intents=intents) + self.channel = channel + + async def on_ready(self): + """Called when bot is ready.""" + logger.info(f"QQ bot ready: {self.robot.name}") + + async def on_c2c_message_create(self, message: "C2CMessage"): + """Handle C2C (Client to Client) messages - private chat.""" + await self.channel._on_message(message, "c2c") + + async def on_direct_message_create(self, message): + """Handle direct messages - alternative event name.""" + await self.channel._on_message(message, "direct") + + # TODO: Group message support - implement in future PRD + # async def on_group_at_message_create(self, message): + # """Handle group @ messages.""" + # pass + + self._client = QQBot(self) + + # Start bot - use create_task to run concurrently + self._bot_task = asyncio.create_task( + self._run_bot_with_retry(self.config.app_id, self.config.secret) + ) + + logger.info("QQ bot started with C2C (private message) support") + + async def _run_bot_with_retry(self, app_id: str, secret: str) -> None: + """Run bot with error handling.""" + try: + await self._client.start(appid=app_id, secret=secret) + except Exception as e: + logger.error( + f"QQ ้‰ดๆƒๅคฑ่ดฅ๏ผŒ่ฏทๆฃ€ๆŸฅ AppID ๅ’Œ Secret ๆ˜ฏๅฆๆญฃ็กฎใ€‚" + f"่ฎฟ้—ฎ q.qq.com ่Žทๅ–ๅ‡ญ่ฏใ€‚้”™่ฏฏ: {e}" + ) + self._running = False + + async def stop(self) -> None: + """Stop the QQ bot.""" + self._running = False + + if self._bot_task: + self._bot_task.cancel() + try: + await self._bot_task + except asyncio.CancelledError: + pass + + logger.info("QQ bot stopped") + + async def send(self, msg: OutboundMessage) -> None: + """Send a message through QQ.""" + if not self._client: + logger.warning("QQ client not initialized") + return + + try: + # Parse chat_id format: qq:{user_id} + channel, user_id = parse_chat_id(msg.chat_id) + + if channel != "qq": + logger.warning(f"Invalid channel in chat_id: {msg.chat_id}") + return + + # Send private message using botpy API + await self._client.api.post_c2c_message( + openid=user_id, + msg_type=0, + content=msg.content, + ) + logger.debug(f"QQ message sent to {msg.chat_id}") + + except ValueError as e: + logger.error(f"Invalid chat_id format: {e}") + except Exception as e: + logger.error(f"Error sending QQ message: {e}") + + async def _on_message(self, data: "C2CMessage", msg_type: str) -> None: + """Handle incoming message from QQ.""" + try: + # Message deduplication using deque with maxlen + message_id = data.id + if message_id in self._processed_message_ids: + logger.debug(f"Duplicate message {message_id}, skipping") + return + + self._processed_message_ids.append(message_id) + + # Extract user ID and chat ID from message + author = data.author + # Try different possible field names for user ID + user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) + user_name = getattr(author, 'username', None) or 'unknown' + + # For C2C messages, chat_id is the user's ID + chat_id = f"qq:{user_id}" + + # Check allow_from list (if configured) + if self.config.allow_from and user_id not in self.config.allow_from: + logger.info(f"User {user_id} not in allow_from list") + return + + # Get message content + content = data.content or "" + + if not content: + logger.debug(f"Empty message from {user_id}, skipping") + return + + # Publish to message bus + msg = InboundMessage( + channel=self.name, + sender_id=user_id, + chat_id=chat_id, + content=content, + metadata={ + "message_id": message_id, + "user_name": user_name, + "msg_type": msg_type, + }, + ) + await self.bus.publish_inbound(msg) + + logger.info(f"Received QQ message from {user_id} ({msg_type}): {content[:50]}") + + except Exception as e: + logger.error(f"Error handling QQ message: {e}") diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index aa9729b..f31d279 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -77,6 +77,14 @@ class EmailConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses +class QQConfig(BaseModel): + """QQ channel configuration using botpy SDK.""" + enabled: bool = False + app_id: str = "" # ๆœบๅ™จไบบ ID (AppID) from q.qq.com + secret: str = "" # ๆœบๅ™จไบบๅฏ†้’ฅ (AppSecret) from q.qq.com + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) @@ -85,6 +93,7 @@ class ChannelsConfig(BaseModel): feishu: FeishuConfig = Field(default_factory=FeishuConfig) dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig) email: EmailConfig = Field(default_factory=EmailConfig) + qq: QQConfig = Field(default_factory=QQConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 6fda084..21b50f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "python-telegram-bot[socks]>=21.0", "lark-oapi>=1.0.0", "socksio>=1.0.0", + "qq-botpy>=1.0.0", ] [project.optional-dependencies] From 20b8a2fc58dd4550c487d9b6f88e38b57a55b4bf Mon Sep 17 00:00:00 2001 From: tjb-tech Date: Mon, 9 Feb 2026 08:46:47 +0000 Subject: [PATCH 34/58] feat(channels): add Moltchat websocket channel with polling fallback --- README.md | 49 +- nanobot/channels/__init__.py | 3 +- nanobot/channels/manager.py | 12 + nanobot/channels/moltchat.py | 1227 ++++++++++++++++++++++++++++++++ nanobot/cli/commands.py | 18 + nanobot/config/schema.py | 37 + pyproject.toml | 2 + tests/test_moltchat_channel.py | 115 +++ 8 files changed, 1459 insertions(+), 4 deletions(-) create mode 100644 nanobot/channels/moltchat.py create mode 100644 tests/test_moltchat_channel.py diff --git a/README.md b/README.md index 8a15892..74c24d9 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, or Feishu โ€” anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, or Moltchat โ€” anytime, anywhere. | Channel | Setup | |---------|-------| @@ -172,6 +172,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu โ€” anytime, | **Discord** | Easy (bot token + intents) | | **WhatsApp** | Medium (scan QR) | | **Feishu** | Medium (app credentials) | +| **Moltchat** | Medium (claw token + websocket) |
Telegram (Recommended) @@ -205,6 +206,48 @@ nanobot gateway
+
+Moltchat (Claw IM) + +Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. + +**1. Prepare credentials** +- `clawToken`: Claw API token +- `agentUserId`: your bot user id +- Optional: `sessions`/`panels` with `["*"]` for auto-discovery + +**2. Configure** + +```json +{ + "channels": { + "moltchat": { + "enabled": true, + "baseUrl": "https://mochat.io", + "socketUrl": "https://mochat.io", + "socketPath": "/socket.io", + "clawToken": "claw_xxx", + "agentUserId": "69820107a785110aea8b1069", + "sessions": ["*"], + "panels": ["*"], + "replyDelayMode": "non-mention", + "replyDelayMs": 120000 + } + } +} +``` + +**3. Run** + +```bash +nanobot gateway +``` + +> [!TIP] +> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Moltchat API endpoint. + +
+
Discord @@ -413,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 Telegram/WhatsApp) +# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Moltchat) docker run -v ~/.nanobot:/root/.nanobot -p 18790:18790 nanobot gateway # Or run a single command @@ -433,7 +476,7 @@ nanobot/ โ”‚ โ”œโ”€โ”€ subagent.py # Background task execution โ”‚ โ””โ”€โ”€ tools/ # Built-in tools (incl. spawn) โ”œโ”€โ”€ skills/ # ๐ŸŽฏ Bundled skills (github, weather, tmux...) -โ”œโ”€โ”€ channels/ # ๐Ÿ“ฑ WhatsApp integration +โ”œโ”€โ”€ channels/ # ๐Ÿ“ฑ Chat channel integrations โ”œโ”€โ”€ bus/ # ๐ŸšŒ Message routing โ”œโ”€โ”€ cron/ # โฐ Scheduled tasks โ”œโ”€โ”€ heartbeat/ # ๐Ÿ’“ Proactive wake-up diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py index 588169d..4d77063 100644 --- a/nanobot/channels/__init__.py +++ b/nanobot/channels/__init__.py @@ -2,5 +2,6 @@ from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager +from nanobot.channels.moltchat import MoltchatChannel -__all__ = ["BaseChannel", "ChannelManager"] +__all__ = ["BaseChannel", "ChannelManager", "MoltchatChannel"] diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 64ced48..11690ef 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -77,6 +77,18 @@ class ChannelManager: logger.info("Feishu channel enabled") except ImportError as e: logger.warning(f"Feishu channel not available: {e}") + + # Moltchat channel + if self.config.channels.moltchat.enabled: + try: + from nanobot.channels.moltchat import MoltchatChannel + + self.channels["moltchat"] = MoltchatChannel( + self.config.channels.moltchat, self.bus + ) + logger.info("Moltchat channel enabled") + except ImportError as e: + logger.warning(f"Moltchat 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/moltchat.py new file mode 100644 index 0000000..cc590d4 --- /dev/null +++ b/nanobot/channels/moltchat.py @@ -0,0 +1,1227 @@ +"""Moltchat channel implementation using Socket.IO with HTTP polling fallback.""" + +from __future__ import annotations + +import asyncio +import json +from collections import deque +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +import httpx +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.utils.helpers import get_data_path + +try: + import socketio + + SOCKETIO_AVAILABLE = True +except ImportError: + socketio = None + SOCKETIO_AVAILABLE = False + +try: + import msgpack # noqa: F401 + + MSGPACK_AVAILABLE = True +except ImportError: + MSGPACK_AVAILABLE = False + + +MAX_SEEN_MESSAGE_IDS = 2000 +CURSOR_SAVE_DEBOUNCE_S = 0.5 + + +@dataclass +class MoltchatBufferedEntry: + """Buffered inbound entry for delayed dispatch.""" + + raw_body: str + author: str + sender_name: str = "" + sender_username: str = "" + timestamp: int | None = None + message_id: str = "" + group_id: str = "" + + +@dataclass +class DelayState: + """Per-target delayed message state.""" + + entries: list[MoltchatBufferedEntry] = field(default_factory=list) + lock: asyncio.Lock = field(default_factory=asyncio.Lock) + timer: asyncio.Task | None = None + + +@dataclass +class MoltchatTarget: + """Outbound target resolution result.""" + + id: str + is_panel: bool + + +def normalize_moltchat_content(content: Any) -> str: + """Normalize content payload to text.""" + if isinstance(content, str): + return content.strip() + if content is None: + return "" + try: + return json.dumps(content, ensure_ascii=False) + except TypeError: + return str(content) + + +def resolve_moltchat_target(raw: str) -> MoltchatTarget: + """Resolve id and target kind from user-provided target string.""" + trimmed = (raw or "").strip() + if not trimmed: + return MoltchatTarget(id="", is_panel=False) + + lowered = trimmed.lower() + cleaned = trimmed + forced_panel = False + + prefixes = ["moltchat:", "mochat:", "group:", "channel:", "panel:"] + for prefix in prefixes: + if lowered.startswith(prefix): + cleaned = trimmed[len(prefix) :].strip() + if prefix in {"group:", "channel:", "panel:"}: + forced_panel = True + break + + if not cleaned: + return MoltchatTarget(id="", is_panel=False) + + return MoltchatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_")) + + +def extract_mention_ids(value: Any) -> list[str]: + """Extract mention ids from heterogeneous mention payload.""" + if not isinstance(value, list): + return [] + + ids: list[str] = [] + for item in value: + if isinstance(item, str): + text = item.strip() + if text: + ids.append(text) + continue + + if not isinstance(item, dict): + continue + + for key in ("id", "userId", "_id"): + candidate = item.get(key) + if isinstance(candidate, str) and candidate.strip(): + ids.append(candidate.strip()) + break + + return ids + + +def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool: + """Resolve mention state from payload metadata and text fallback.""" + meta = payload.get("meta") + if isinstance(meta, dict): + if meta.get("mentioned") is True or meta.get("wasMentioned") is True: + return True + + for field in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"): + ids = extract_mention_ids(meta.get(field)) + if agent_user_id and agent_user_id in ids: + return True + + if not agent_user_id: + return False + + content = payload.get("content") + if not isinstance(content, str) or not content: + return False + + return f"<@{agent_user_id}>" in content or f"@{agent_user_id}" in content + + +def resolve_require_mention( + config: MoltchatConfig, + session_id: str, + group_id: str, +) -> bool: + """Resolve mention requirement for group/panel conversations.""" + groups = config.groups or {} + if group_id and group_id in groups: + return bool(groups[group_id].require_mention) + if session_id in groups: + return bool(groups[session_id].require_mention) + if "*" in groups: + return bool(groups["*"].require_mention) + return bool(config.mention.require_in_groups) + + +def build_buffered_body(entries: list[MoltchatBufferedEntry], is_group: bool) -> str: + """Build text body from one or more buffered entries.""" + if not entries: + return "" + + if len(entries) == 1: + return entries[0].raw_body + + lines: list[str] = [] + for entry in entries: + body = entry.raw_body + if not body: + continue + if is_group: + label = entry.sender_name.strip() or entry.sender_username.strip() or entry.author + if label: + lines.append(f"{label}: {body}") + continue + lines.append(body) + + return "\n".join(lines).strip() + + +def parse_timestamp(value: Any) -> int | None: + """Parse event timestamp to epoch milliseconds.""" + if not isinstance(value, str) or not value.strip(): + return None + try: + return int(datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() * 1000) + except ValueError: + return None + + +class MoltchatChannel(BaseChannel): + """Moltchat channel using socket.io with fallback polling workers.""" + + name = "moltchat" + + def __init__(self, config: MoltchatConfig, bus: MessageBus): + super().__init__(config, bus) + self.config: MoltchatConfig = 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._cursor_path = self._state_dir / "session_cursors.json" + self._session_cursor: dict[str, int] = {} + self._cursor_save_task: asyncio.Task | None = None + + self._session_set: set[str] = set() + self._panel_set: set[str] = set() + self._auto_discover_sessions = False + self._auto_discover_panels = False + + self._cold_sessions: set[str] = set() + self._session_by_converse: dict[str, str] = {} + + self._seen_set: dict[str, set[str]] = {} + self._seen_queue: dict[str, deque[str]] = {} + + self._delay_states: dict[str, DelayState] = {} + + self._fallback_mode = False + self._session_fallback_tasks: dict[str, asyncio.Task] = {} + self._panel_fallback_tasks: dict[str, asyncio.Task] = {} + self._refresh_task: asyncio.Task | None = None + + self._target_locks: dict[str, asyncio.Lock] = {} + + async def start(self) -> None: + """Start Moltchat channel workers and websocket connection.""" + if not self.config.claw_token: + logger.error("Moltchat claw_token not configured") + return + + self._running = True + self._http = httpx.AsyncClient(timeout=30.0) + + self._state_dir.mkdir(parents=True, exist_ok=True) + await self._load_session_cursors() + self._seed_targets_from_config() + + await self._refresh_targets(subscribe_new=False) + + websocket_started = await self._start_socket_client() + if not websocket_started: + await self._ensure_fallback_workers() + + self._refresh_task = asyncio.create_task(self._refresh_loop()) + + while self._running: + await asyncio.sleep(1) + + async def stop(self) -> None: + """Stop all workers and clean up resources.""" + self._running = False + + if self._refresh_task: + self._refresh_task.cancel() + self._refresh_task = None + + await self._stop_fallback_workers() + await self._cancel_delay_timers() + + if self._socket: + try: + await self._socket.disconnect() + except Exception: + pass + self._socket = None + + if self._cursor_save_task: + self._cursor_save_task.cancel() + self._cursor_save_task = None + + await self._save_session_cursors() + + if self._http: + await self._http.aclose() + self._http = None + + self._ws_connected = False + self._ws_ready = False + + 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") + return + + content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else [] + if msg.media: + content_parts.extend([m for m in msg.media if isinstance(m, str) and m.strip()]) + content = "\n".join(content_parts).strip() + if not content: + return + + target = resolve_moltchat_target(msg.chat_id) + if not target.id: + logger.warning("Moltchat outbound target is empty") + return + + is_panel = target.is_panel or target.id in self._panel_set + if target.id.startswith("session_"): + is_panel = False + + try: + if is_panel: + await self._send_panel_message( + panel_id=target.id, + content=content, + reply_to=msg.reply_to, + group_id=self._read_group_id(msg.metadata), + ) + else: + await self._send_session_message( + session_id=target.id, + content=content, + reply_to=msg.reply_to, + ) + except Exception as e: + logger.error(f"Failed to send Moltchat message: {e}") + + def _seed_targets_from_config(self) -> None: + sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions) + panels, self._auto_discover_panels = self._normalize_id_list(self.config.panels) + + self._session_set.update(sessions) + self._panel_set.update(panels) + + for session_id in sessions: + if session_id not in self._session_cursor: + self._cold_sessions.add(session_id) + + def _normalize_id_list(self, values: list[str]) -> tuple[list[str], bool]: + cleaned = [str(v).strip() for v in values if str(v).strip()] + has_wildcard = "*" in cleaned + ids = sorted({v for v in cleaned if v != "*"}) + return ids, has_wildcard + + async def _start_socket_client(self) -> bool: + if not SOCKETIO_AVAILABLE: + logger.warning("python-socketio not installed, Moltchat using polling fallback") + return False + + serializer = "default" + if not self.config.socket_disable_msgpack: + if MSGPACK_AVAILABLE: + serializer = "msgpack" + else: + logger.warning( + "msgpack is not installed but socket_disable_msgpack=false; " + "trying JSON serializer" + ) + + reconnect_attempts = None + if self.config.max_retry_attempts > 0: + reconnect_attempts = self.config.max_retry_attempts + + client = socketio.AsyncClient( + reconnection=True, + reconnection_attempts=reconnect_attempts, + reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0), + reconnection_delay_max=max( + 0.1, + self.config.socket_max_reconnect_delay_ms / 1000.0, + ), + logger=False, + engineio_logger=False, + serializer=serializer, + ) + + @client.event + async def connect() -> None: + self._ws_connected = True + self._ws_ready = False + logger.info("Moltchat websocket connected") + + subscribed = await self._subscribe_all() + self._ws_ready = subscribed + if subscribed: + await self._stop_fallback_workers() + else: + await self._ensure_fallback_workers() + + @client.event + async def disconnect() -> None: + if not self._running: + return + self._ws_connected = False + self._ws_ready = False + logger.warning("Moltchat 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}") + + @client.on("claw.session.events") + async def on_session_events(payload: dict[str, Any]) -> None: + await self._handle_watch_payload(payload, target_kind="session") + + @client.on("claw.panel.events") + async def on_panel_events(payload: dict[str, Any]) -> None: + await self._handle_watch_payload(payload, target_kind="panel") + + for event_name in ( + "notify:chat.inbox.append", + "notify:chat.message.add", + "notify:chat.message.update", + "notify:chat.message.recall", + "notify:chat.message.delete", + ): + client.on(event_name, self._build_notify_handler(event_name)) + + socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/") + socket_path = (self.config.socket_path or "/socket.io").strip() + if socket_path.startswith("/"): + socket_path = socket_path[1:] + + try: + self._socket = client + await client.connect( + socket_url, + transports=["websocket"], + socketio_path=socket_path, + auth={"token": self.config.claw_token}, + wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0), + ) + return True + except Exception as e: + logger.error(f"Failed to connect Moltchat websocket: {e}") + try: + await client.disconnect() + except Exception: + pass + self._socket = None + return False + + def _build_notify_handler(self, event_name: str): + async def handler(payload: Any) -> None: + if event_name == "notify:chat.inbox.append": + await self._handle_notify_inbox_append(payload) + return + + if event_name.startswith("notify:chat.message."): + await self._handle_notify_chat_message(payload) + + return handler + + async def _subscribe_all(self) -> bool: + sessions_ok = await self._subscribe_sessions(sorted(self._session_set)) + panels_ok = await self._subscribe_panels(sorted(self._panel_set)) + + if self._auto_discover_sessions or self._auto_discover_panels: + await self._refresh_targets(subscribe_new=True) + + return sessions_ok and panels_ok + + async def _subscribe_sessions(self, session_ids: list[str]) -> bool: + if not session_ids: + return True + + for session_id in session_ids: + if session_id not in self._session_cursor: + self._cold_sessions.add(session_id) + + ack = await self._socket_call( + "com.claw.im.subscribeSessions", + { + "sessionIds": session_ids, + "cursors": self._session_cursor, + "limit": self.config.watch_limit, + }, + ) + if not ack.get("result"): + logger.error(f"Moltchat subscribeSessions failed: {ack.get('message', 'unknown error')}") + return False + + data = ack.get("data") + items: list[dict[str, Any]] = [] + if isinstance(data, list): + items = [item for item in data if isinstance(item, dict)] + elif isinstance(data, dict): + sessions = data.get("sessions") + if isinstance(sessions, list): + items = [item for item in sessions if isinstance(item, dict)] + elif "sessionId" in data: + items = [data] + + for payload in items: + await self._handle_watch_payload(payload, target_kind="session") + + return True + + async def _subscribe_panels(self, panel_ids: list[str]) -> bool: + if not self._auto_discover_panels and not panel_ids: + return True + + ack = await self._socket_call( + "com.claw.im.subscribePanels", + { + "panelIds": panel_ids, + }, + ) + if not ack.get("result"): + logger.error(f"Moltchat subscribePanels failed: {ack.get('message', 'unknown error')}") + return False + + return True + + async def _socket_call(self, event_name: str, payload: dict[str, Any]) -> dict[str, Any]: + if not self._socket: + return {"result": False, "message": "socket not connected"} + + try: + raw = await self._socket.call(event_name, payload, timeout=10) + except Exception as e: + return {"result": False, "message": str(e)} + + if isinstance(raw, dict): + return raw + + return {"result": True, "data": raw} + + async def _refresh_loop(self) -> None: + interval_s = max(1.0, self.config.refresh_interval_ms / 1000.0) + + while self._running: + await asyncio.sleep(interval_s) + + try: + await self._refresh_targets(subscribe_new=self._ws_ready) + except Exception as e: + logger.warning(f"Moltchat refresh failed: {e}") + + if self._fallback_mode: + await self._ensure_fallback_workers() + + async def _refresh_targets(self, subscribe_new: bool) -> None: + if self._auto_discover_sessions: + await self._refresh_sessions_directory(subscribe_new=subscribe_new) + + if self._auto_discover_panels: + await self._refresh_panels(subscribe_new=subscribe_new) + + async def _refresh_sessions_directory(self, subscribe_new: bool) -> None: + try: + response = await self._list_sessions() + except Exception as e: + logger.warning(f"Moltchat listSessions failed: {e}") + return + + sessions = response.get("sessions") + if not isinstance(sessions, list): + return + + new_sessions: list[str] = [] + for session in sessions: + if not isinstance(session, dict): + continue + + session_id = str(session.get("sessionId") or "").strip() + if not session_id: + continue + + if session_id not in self._session_set: + self._session_set.add(session_id) + new_sessions.append(session_id) + if session_id not in self._session_cursor: + self._cold_sessions.add(session_id) + + converse_id = str(session.get("converseId") or "").strip() + if converse_id: + self._session_by_converse[converse_id] = session_id + + if not new_sessions: + return + + if self._ws_ready and subscribe_new: + await self._subscribe_sessions(new_sessions) + + if self._fallback_mode: + await self._ensure_fallback_workers() + + async def _refresh_panels(self, subscribe_new: bool) -> None: + try: + response = await self._get_workspace_group() + except Exception as e: + logger.warning(f"Moltchat getWorkspaceGroup failed: {e}") + return + + raw_panels = response.get("panels") + if not isinstance(raw_panels, list): + return + + new_panels: list[str] = [] + for panel in raw_panels: + if not isinstance(panel, dict): + continue + + panel_type = panel.get("type") + if isinstance(panel_type, int) and panel_type != 0: + continue + + panel_id = str(panel.get("id") or panel.get("_id") or "").strip() + if not panel_id: + continue + + if panel_id not in self._panel_set: + self._panel_set.add(panel_id) + new_panels.append(panel_id) + + if not new_panels: + return + + if self._ws_ready and subscribe_new: + await self._subscribe_panels(new_panels) + + if self._fallback_mode: + await self._ensure_fallback_workers() + + async def _ensure_fallback_workers(self) -> None: + if not self._running: + return + + self._fallback_mode = True + + for session_id in sorted(self._session_set): + task = self._session_fallback_tasks.get(session_id) + if task and not task.done(): + continue + self._session_fallback_tasks[session_id] = asyncio.create_task( + self._session_watch_worker(session_id) + ) + + for panel_id in sorted(self._panel_set): + task = self._panel_fallback_tasks.get(panel_id) + if task and not task.done(): + continue + self._panel_fallback_tasks[panel_id] = asyncio.create_task( + self._panel_poll_worker(panel_id) + ) + + async def _stop_fallback_workers(self) -> None: + self._fallback_mode = False + + tasks = [ + *self._session_fallback_tasks.values(), + *self._panel_fallback_tasks.values(), + ] + for task in tasks: + task.cancel() + + if tasks: + await asyncio.gather(*tasks, return_exceptions=True) + + self._session_fallback_tasks.clear() + self._panel_fallback_tasks.clear() + + async def _session_watch_worker(self, session_id: str) -> None: + while self._running and self._fallback_mode: + try: + payload = await self._watch_session( + session_id=session_id, + cursor=self._session_cursor.get(session_id, 0), + timeout_ms=self.config.watch_timeout_ms, + limit=self.config.watch_limit, + ) + await self._handle_watch_payload(payload, target_kind="session") + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Moltchat 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: + sleep_s = max(1.0, self.config.refresh_interval_ms / 1000.0) + + while self._running and self._fallback_mode: + try: + response = await self._list_panel_messages( + panel_id=panel_id, + limit=min(100, max(1, self.config.watch_limit)), + ) + + raw_messages = response.get("messages") + if isinstance(raw_messages, list): + for message in reversed(raw_messages): + if not isinstance(message, dict): + continue + + synthetic_event = { + "type": "message.add", + "timestamp": message.get("createdAt") or datetime.utcnow().isoformat(), + "payload": { + "messageId": str(message.get("messageId") or ""), + "author": str(message.get("author") or ""), + "authorInfo": message.get("authorInfo") if isinstance(message.get("authorInfo"), dict) else {}, + "content": message.get("content"), + "meta": message.get("meta") if isinstance(message.get("meta"), dict) else {}, + "groupId": str(response.get("groupId") or ""), + "converseId": panel_id, + }, + } + await self._process_inbound_event( + target_id=panel_id, + event=synthetic_event, + target_kind="panel", + ) + except asyncio.CancelledError: + break + except Exception as e: + logger.warning(f"Moltchat panel polling error ({panel_id}): {e}") + + await asyncio.sleep(sleep_s) + + async def _handle_watch_payload( + self, + payload: dict[str, Any], + target_kind: str, + ) -> None: + if not isinstance(payload, dict): + return + + target_id = str(payload.get("sessionId") or "").strip() + if not target_id: + return + + lock = self._target_locks.setdefault(f"{target_kind}:{target_id}", asyncio.Lock()) + async with lock: + previous_cursor = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0 + payload_cursor = payload.get("cursor") + if ( + target_kind == "session" + and isinstance(payload_cursor, int) + and payload_cursor >= 0 + ): + self._mark_session_cursor(target_id, payload_cursor) + + raw_events = payload.get("events") + if not isinstance(raw_events, list): + return + + if target_kind == "session" and target_id in self._cold_sessions: + self._cold_sessions.discard(target_id) + return + + for event in raw_events: + if not isinstance(event, dict): + continue + seq = event.get("seq") + if ( + target_kind == "session" + and isinstance(seq, int) + and seq > self._session_cursor.get(target_id, previous_cursor) + ): + self._mark_session_cursor(target_id, seq) + + if event.get("type") != "message.add": + continue + + await self._process_inbound_event( + target_id=target_id, + event=event, + target_kind=target_kind, + ) + + async def _process_inbound_event( + self, + target_id: str, + event: dict[str, Any], + target_kind: str, + ) -> None: + payload = event.get("payload") + if not isinstance(payload, dict): + return + + author = str(payload.get("author") or "").strip() + if not author: + return + + if self.config.agent_user_id and author == self.config.agent_user_id: + return + + if not self.is_allowed(author): + return + + message_id = str(payload.get("messageId") or "").strip() + seen_key = f"{target_kind}:{target_id}" + if message_id and self._remember_message_id(seen_key, message_id): + return + + raw_body = normalize_moltchat_content(payload.get("content")) + if not raw_body: + raw_body = "[empty message]" + + author_info = payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {} + sender_name = str(author_info.get("nickname") or author_info.get("email") or "").strip() + sender_username = str(author_info.get("agentId") or "").strip() + + group_id = str(payload.get("groupId") or "").strip() + is_group = bool(group_id) + was_mentioned = resolve_was_mentioned(payload, self.config.agent_user_id) + + require_mention = ( + target_kind == "panel" + and is_group + and resolve_require_mention(self.config, target_id, group_id) + ) + + use_delay = target_kind == "panel" and self.config.reply_delay_mode == "non-mention" + + if require_mention and not was_mentioned and not use_delay: + return + + entry = MoltchatBufferedEntry( + raw_body=raw_body, + author=author, + sender_name=sender_name, + sender_username=sender_username, + timestamp=parse_timestamp(event.get("timestamp")), + message_id=message_id, + group_id=group_id, + ) + + if use_delay: + delay_key = f"{target_kind}:{target_id}" + if was_mentioned: + await self._flush_delayed_entries( + key=delay_key, + target_id=target_id, + target_kind=target_kind, + reason="mention", + entry=entry, + ) + else: + await self._enqueue_delayed_entry( + key=delay_key, + target_id=target_id, + target_kind=target_kind, + entry=entry, + ) + return + + await self._dispatch_entries( + target_id=target_id, + target_kind=target_kind, + entries=[entry], + was_mentioned=was_mentioned, + ) + + def _remember_message_id(self, key: str, message_id: str) -> bool: + seen_set = self._seen_set.setdefault(key, set()) + seen_queue = self._seen_queue.setdefault(key, deque()) + + if message_id in seen_set: + return True + + seen_set.add(message_id) + seen_queue.append(message_id) + + while len(seen_queue) > MAX_SEEN_MESSAGE_IDS: + removed = seen_queue.popleft() + seen_set.discard(removed) + + return False + + async def _enqueue_delayed_entry( + self, + key: str, + target_id: str, + target_kind: str, + entry: MoltchatBufferedEntry, + ) -> None: + state = self._delay_states.setdefault(key, DelayState()) + + async with state.lock: + state.entries.append(entry) + if state.timer: + state.timer.cancel() + + state.timer = asyncio.create_task( + self._delay_flush_after(key, target_id, target_kind) + ) + + async def _delay_flush_after(self, key: str, target_id: str, target_kind: str) -> None: + await asyncio.sleep(max(0, self.config.reply_delay_ms) / 1000.0) + await self._flush_delayed_entries( + key=key, + target_id=target_id, + target_kind=target_kind, + reason="timer", + entry=None, + ) + + async def _flush_delayed_entries( + self, + key: str, + target_id: str, + target_kind: str, + reason: str, + entry: MoltchatBufferedEntry | None, + ) -> None: + state = self._delay_states.setdefault(key, DelayState()) + + async with state.lock: + if entry: + state.entries.append(entry) + + current = asyncio.current_task() + if state.timer and state.timer is not current: + state.timer.cancel() + state.timer = None + elif state.timer is current: + state.timer = None + + entries = state.entries[:] + state.entries.clear() + + if not entries: + return + + await self._dispatch_entries( + target_id=target_id, + target_kind=target_kind, + entries=entries, + was_mentioned=(reason == "mention"), + ) + + async def _dispatch_entries( + self, + target_id: str, + target_kind: str, + entries: list[MoltchatBufferedEntry], + was_mentioned: bool, + ) -> None: + if not entries: + return + + is_group = bool(entries[-1].group_id) + body = build_buffered_body(entries, is_group) + if not body: + body = "[empty message]" + + last = entries[-1] + metadata = { + "message_id": last.message_id, + "timestamp": last.timestamp, + "is_group": is_group, + "group_id": last.group_id, + "sender_name": last.sender_name, + "sender_username": last.sender_username, + "target_kind": target_kind, + "was_mentioned": was_mentioned, + "buffered_count": len(entries), + } + + await self._handle_message( + sender_id=last.author, + chat_id=target_id, + content=body, + metadata=metadata, + ) + + async def _cancel_delay_timers(self) -> None: + for state in self._delay_states.values(): + if state.timer: + state.timer.cancel() + state.timer = None + self._delay_states.clear() + + async def _handle_notify_chat_message(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + + group_id = str(payload.get("groupId") or "").strip() + panel_id = str(payload.get("converseId") or payload.get("panelId") or "").strip() + if not group_id or not panel_id: + return + + if self._panel_set and panel_id not in self._panel_set: + return + + synthetic_event = { + "type": "message.add", + "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(), + "payload": { + "messageId": str(payload.get("_id") or payload.get("messageId") or ""), + "author": str(payload.get("author") or ""), + "authorInfo": payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {}, + "content": payload.get("content"), + "meta": payload.get("meta") if isinstance(payload.get("meta"), dict) else {}, + "groupId": group_id, + "converseId": panel_id, + }, + } + await self._process_inbound_event( + target_id=panel_id, + event=synthetic_event, + target_kind="panel", + ) + + async def _handle_notify_inbox_append(self, payload: Any) -> None: + if not isinstance(payload, dict): + return + + if payload.get("type") != "message": + return + + detail = payload.get("payload") + if not isinstance(detail, dict): + return + + group_id = str(detail.get("groupId") or "").strip() + if group_id: + return + + converse_id = str(detail.get("converseId") or "").strip() + if not converse_id: + return + + session_id = self._session_by_converse.get(converse_id) + if not session_id: + await self._refresh_sessions_directory(subscribe_new=self._ws_ready) + session_id = self._session_by_converse.get(converse_id) + if not session_id: + return + + message_id = str(detail.get("messageId") or payload.get("_id") or "").strip() + author = str(detail.get("messageAuthor") or "").strip() + content = str(detail.get("messagePlainContent") or detail.get("messageSnippet") or "").strip() + + synthetic_event = { + "type": "message.add", + "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(), + "payload": { + "messageId": message_id, + "author": author, + "content": content, + "meta": { + "source": "notify:chat.inbox.append", + "converseId": converse_id, + }, + "converseId": converse_id, + }, + } + + await self._process_inbound_event( + target_id=session_id, + event=synthetic_event, + target_kind="session", + ) + + def _mark_session_cursor(self, session_id: str, cursor: int) -> None: + if cursor < 0: + return + + previous = self._session_cursor.get(session_id, 0) + if cursor < previous: + return + + self._session_cursor[session_id] = cursor + self._schedule_cursor_save() + + def _schedule_cursor_save(self) -> None: + if self._cursor_save_task and not self._cursor_save_task.done(): + return + + self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced()) + + async def _save_cursor_debounced(self) -> None: + await asyncio.sleep(CURSOR_SAVE_DEBOUNCE_S) + await self._save_session_cursors() + + async def _load_session_cursors(self) -> None: + if not self._cursor_path.exists(): + return + + 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}") + return + + cursors = data.get("cursors") if isinstance(data, dict) else None + if not isinstance(cursors, dict): + return + + for session_id, cursor in cursors.items(): + if isinstance(session_id, str) and isinstance(cursor, int) and cursor >= 0: + self._session_cursor[session_id] = cursor + + async def _save_session_cursors(self) -> None: + payload = { + "schemaVersion": 1, + "updatedAt": datetime.utcnow().isoformat(), + "cursors": self._session_cursor, + } + + try: + 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}") + + 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") + + url = f"{self._base_url()}{path}" + response = await self._http.post( + url, + headers={ + "Content-Type": "application/json", + "X-Claw-Token": self.config.claw_token, + }, + json=payload, + ) + + text = response.text + if not response.is_success: + raise RuntimeError(f"Moltchat HTTP {response.status_code}: {text[:200]}") + + parsed: Any + try: + parsed = response.json() + except Exception: + parsed = text + + 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']})") + data = parsed.get("data") + return data if isinstance(data, dict) else {} + + if isinstance(parsed, dict): + return parsed + + return {} + + async def _watch_session( + self, + session_id: str, + cursor: int, + timeout_ms: int, + limit: int, + ) -> dict[str, Any]: + return await self._post_json( + "/api/claw/sessions/watch", + { + "sessionId": session_id, + "cursor": cursor, + "timeoutMs": timeout_ms, + "limit": limit, + }, + ) + + async def _send_session_message( + self, + session_id: str, + content: str, + reply_to: str | None, + ) -> dict[str, Any]: + payload = { + "sessionId": session_id, + "content": content, + } + if reply_to: + payload["replyTo"] = reply_to + return await self._post_json("/api/claw/sessions/send", payload) + + async def _send_panel_message( + self, + panel_id: str, + content: str, + reply_to: str | None, + group_id: str | None, + ) -> dict[str, Any]: + payload = { + "panelId": panel_id, + "content": content, + } + if reply_to: + payload["replyTo"] = reply_to + if group_id: + payload["groupId"] = group_id + return await self._post_json("/api/claw/groups/panels/send", payload) + + async def _list_sessions(self) -> dict[str, Any]: + return await self._post_json("/api/claw/sessions/list", {}) + + async def _get_workspace_group(self) -> dict[str, Any]: + return await self._post_json("/api/claw/groups/get", {}) + + async def _list_panel_messages(self, panel_id: str, limit: int) -> dict[str, Any]: + return await self._post_json( + "/api/claw/groups/panels/messages", + { + "panelId": panel_id, + "limit": limit, + }, + ) + + def _read_group_id(self, metadata: dict[str, Any]) -> str | None: + if not isinstance(metadata, dict): + return None + value = metadata.get("group_id") or metadata.get("groupId") + if isinstance(value, str) and value.strip(): + return value.strip() + return None diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 19e62e9..2039f82 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -366,6 +366,24 @@ def channels_status(): "โœ“" 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 + ) + + # Moltchat + mc = config.channels.moltchat + mc_base = mc.base_url or "[dim]not configured[/dim]" + table.add_row( + "Moltchat", + "โœ“" if mc.enabled else "โœ—", + mc_base + ) # Telegram tg = config.channels.telegram diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 7724288..4df4251 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -39,12 +39,49 @@ class DiscordConfig(BaseModel): intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT +class MoltchatMentionConfig(BaseModel): + """Moltchat mention behavior configuration.""" + require_in_groups: bool = False + + +class MoltchatGroupRule(BaseModel): + """Moltchat per-group mention requirement.""" + require_mention: bool = False + + +class MoltchatConfig(BaseModel): + """Moltchat channel configuration.""" + enabled: bool = False + base_url: str = "http://localhost:11000" + socket_url: str = "" + socket_path: str = "/socket.io" + socket_disable_msgpack: bool = False + socket_reconnect_delay_ms: int = 1000 + socket_max_reconnect_delay_ms: int = 10000 + socket_connect_timeout_ms: int = 10000 + refresh_interval_ms: int = 30000 + watch_timeout_ms: int = 25000 + watch_limit: int = 100 + retry_delay_ms: int = 500 + max_retry_attempts: int = 0 # 0 means unlimited retries + claw_token: str = "" + agent_user_id: str = "" + 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) + reply_delay_mode: str = "non-mention" # off | non-mention + reply_delay_ms: int = 120000 + + class ChannelsConfig(BaseModel): """Configuration for chat channels.""" whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) feishu: FeishuConfig = Field(default_factory=FeishuConfig) + moltchat: MoltchatConfig = Field(default_factory=MoltchatConfig) class AgentDefaults(BaseModel): diff --git a/pyproject.toml b/pyproject.toml index 2a952a1..81d38b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,8 @@ dependencies = [ "croniter>=2.0.0", "python-telegram-bot>=21.0", "lark-oapi>=1.0.0", + "python-socketio>=5.11.0", + "msgpack>=1.0.8", ] [project.optional-dependencies] diff --git a/tests/test_moltchat_channel.py b/tests/test_moltchat_channel.py new file mode 100644 index 0000000..1f65a68 --- /dev/null +++ b/tests/test_moltchat_channel.py @@ -0,0 +1,115 @@ +import pytest + +from nanobot.bus.queue import MessageBus +from nanobot.channels.moltchat import ( + MoltchatBufferedEntry, + MoltchatChannel, + build_buffered_body, + resolve_moltchat_target, + resolve_require_mention, + resolve_was_mentioned, +) +from nanobot.config.schema import MoltchatConfig, MoltchatGroupRule, MoltchatMentionConfig + + +def test_resolve_moltchat_target_prefixes() -> None: + t = resolve_moltchat_target("panel:abc") + assert t.id == "abc" + assert t.is_panel is True + + t = resolve_moltchat_target("session_123") + assert t.id == "session_123" + assert t.is_panel is False + + t = resolve_moltchat_target("mochat:session_456") + assert t.id == "session_456" + assert t.is_panel is False + + +def test_resolve_was_mentioned_from_meta_and_text() -> None: + payload = { + "content": "hello", + "meta": { + "mentionIds": ["bot-1"], + }, + } + assert resolve_was_mentioned(payload, "bot-1") is True + + payload = {"content": "ping <@bot-2>", "meta": {}} + assert resolve_was_mentioned(payload, "bot-2") is True + + +def test_resolve_require_mention_priority() -> None: + cfg = MoltchatConfig( + groups={ + "*": MoltchatGroupRule(require_mention=False), + "group-a": MoltchatGroupRule(require_mention=True), + }, + mention=MoltchatMentionConfig(require_in_groups=False), + ) + + assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-a") is True + assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-b") is False + + +@pytest.mark.asyncio +async def test_delay_buffer_flushes_on_mention() -> None: + bus = MessageBus() + cfg = MoltchatConfig( + enabled=True, + claw_token="token", + agent_user_id="bot", + reply_delay_mode="non-mention", + reply_delay_ms=60_000, + ) + channel = MoltchatChannel(cfg, bus) + + first = { + "type": "message.add", + "timestamp": "2026-02-07T10:00:00Z", + "payload": { + "messageId": "m1", + "author": "user1", + "content": "first", + "groupId": "group-1", + "meta": {}, + }, + } + second = { + "type": "message.add", + "timestamp": "2026-02-07T10:00:01Z", + "payload": { + "messageId": "m2", + "author": "user2", + "content": "hello <@bot>", + "groupId": "group-1", + "meta": {}, + }, + } + + await channel._process_inbound_event(target_id="panel-1", event=first, target_kind="panel") + assert bus.inbound_size == 0 + + await channel._process_inbound_event(target_id="panel-1", event=second, target_kind="panel") + assert bus.inbound_size == 1 + + msg = await bus.consume_inbound() + assert msg.channel == "moltchat" + assert msg.chat_id == "panel-1" + assert "user1: first" in msg.content + assert "user2: hello <@bot>" in msg.content + assert msg.metadata.get("buffered_count") == 2 + + await channel._cancel_delay_timers() + + +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"), + ], + is_group=True, + ) + assert "Alice: a" in body + assert "bot: b" in body From 377922591788ae7c1fb84e83b9ba1a3e29fd893f Mon Sep 17 00:00:00 2001 From: tjb-tech Date: Mon, 9 Feb 2026 08:50:17 +0000 Subject: [PATCH 35/58] refactor(channels): rename moltchat integration to mochat --- README.md | 12 +-- nanobot/channels/__init__.py | 4 +- nanobot/channels/manager.py | 14 +-- nanobot/channels/{moltchat.py => mochat.py} | 94 +++++++++---------- nanobot/cli/commands.py | 6 +- nanobot/config/schema.py | 18 ++-- ...chat_channel.py => test_mochat_channel.py} | 36 +++---- 7 files changed, 92 insertions(+), 92 deletions(-) rename nanobot/channels/{moltchat.py => mochat.py} (92%) rename tests/{test_moltchat_channel.py => test_mochat_channel.py} (73%) 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, ) From 866942eedd02a3fc85ae4e0393c450a2394b8922 Mon Sep 17 00:00:00 2001 From: tjb-tech Date: Mon, 9 Feb 2026 09:12:53 +0000 Subject: [PATCH 36/58] fix: update agentUserId in README and change base_url to HTTPS in configuration --- README.md | 2 +- nanobot/config/schema.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d15fd2f..7bf98fd 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. "socketUrl": "https://mochat.io", "socketPath": "/socket.io", "clawToken": "claw_xxx", - "agentUserId": "69820107a785110aea8b1069", + "agentUserId": "6982abcdef", "sessions": ["*"], "panels": ["*"], "replyDelayMode": "non-mention", diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index a3d8aa5..26abcd7 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -90,7 +90,7 @@ class MochatGroupRule(BaseModel): class MochatConfig(BaseModel): """Mochat channel configuration.""" enabled: bool = False - base_url: str = "http://localhost:11000" + base_url: str = "https://mochat.io" socket_url: str = "" socket_path: str = "/socket.io" socket_disable_msgpack: bool = False From 7ffd90aa3b519278ca3eda0ee2ab2a0bba430c98 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 10:59:16 +0000 Subject: [PATCH 37/58] docs: update email channel tips --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 8f7c1a2..4106b2a 100644 --- a/README.md +++ b/README.md @@ -494,7 +494,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot ### Security -> [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. | Option | Default | Description | From f3ab8066a70c72dfc9788f3f1c6ba912456133cd Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 11:39:13 +0000 Subject: [PATCH 38/58] fix: use websockets backend, simplify subtype check, add Slack docs --- README.md | 43 +++++++++++++++++++++++++++++++++++++-- nanobot/agent/loop.py | 2 +- nanobot/channels/slack.py | 6 +++--- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4106b2a..186fe35 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!" ## ๐Ÿ’ฌ Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or Email โ€” anytime, anywhere. +Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, or Email โ€” anytime, anywhere. | Channel | Setup | |---------|-------| @@ -175,6 +175,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, or E | **WhatsApp** | Medium (scan QR) | | **Feishu** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) | +| **Slack** | Medium (bot + app tokens) | | **Email** | Medium (IMAP/SMTP credentials) |
@@ -374,6 +375,44 @@ nanobot gateway
+
+Slack + +Uses **Socket Mode** โ€” no public URL required. + +**1. Create a Slack app** +- Go to [Slack API](https://api.slack.com/apps) โ†’ Create New App +- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read` +- Install to your workspace and copy the **Bot Token** (`xoxb-...`) +- **Socket Mode**: Enable it and generate an **App-Level Token** (`xapp-...`) with `connections:write` scope +- **Event Subscriptions**: Subscribe to `message.im`, `message.channels`, `app_mention` + +**2. Configure** + +```json +{ + "channels": { + "slack": { + "enabled": true, + "botToken": "xoxb-...", + "appToken": "xapp-...", + "groupPolicy": "mention" + } + } +} +``` + +> `groupPolicy`: `"mention"` (respond only when @mentioned), `"open"` (respond to all messages), or `"allowlist"` (restrict to specific channels). +> DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. + +**3. Run** + +```bash +nanobot gateway +``` + +
+
Email @@ -592,7 +631,7 @@ PRs welcome! The codebase is intentionally small and readable. ๐Ÿค— - [ ] **Multi-modal** โ€” See and hear (images, voice, video) - [ ] **Long-term memory** โ€” Never forget important context - [ ] **Better reasoning** โ€” Multi-step planning and reflection -- [ ] **More integrations** โ€” Slack, calendar, and more +- [ ] **More integrations** โ€” Calendar and more - [ ] **Self-improvement** โ€” Learn from feedback and mistakes ### Contributors diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 64c95ba..b764c3d 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -246,7 +246,7 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content=final_content, - metadata=msg.metadata or {}, + metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts) ) async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None: diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 32abe3b..be95dd2 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -5,7 +5,7 @@ import re from typing import Any from loguru import logger -from slack_sdk.socket_mode.aiohttp import SocketModeClient +from slack_sdk.socket_mode.websockets import SocketModeClient from slack_sdk.socket_mode.request import SocketModeRequest from slack_sdk.socket_mode.response import SocketModeResponse from slack_sdk.web.async_client import AsyncWebClient @@ -115,8 +115,8 @@ class SlackChannel(BaseChannel): sender_id = event.get("user") chat_id = event.get("channel") - # Ignore bot/system messages to prevent loops - if event.get("subtype") == "bot_message" or event.get("subtype"): + # Ignore bot/system messages (any subtype = not a normal user message) + if event.get("subtype"): return if self._bot_user_id and sender_id == self._bot_user_id: return From a63a44fa798aa86a3f6a79d12db2e38700d4e068 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 12:04:34 +0000 Subject: [PATCH 39/58] fix: align QQ channel with BaseChannel conventions, simplify implementation --- .gitignore | 2 +- nanobot/channels/qq.py | 156 ++++++++++------------------------------- 2 files changed, 39 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 4e58574..36dbfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ __pycache__/ poetry.lock .pytest_cache/ tests/ -botpy.log \ No newline at end of file +botpy.log diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 98ca883..e3efb4f 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from loguru import logger -from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import QQConfig @@ -25,31 +25,28 @@ if TYPE_CHECKING: from botpy.message import C2CMessage -def parse_chat_id(chat_id: str) -> tuple[str, str]: - """Parse chat_id into (channel, user_id). +def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": + """Create a botpy Client subclass bound to the given channel.""" + intents = botpy.Intents(c2c_message=True) - Args: - chat_id: Format "channel:user_id", e.g. "qq:openid_xxx" + class _Bot(botpy.Client): + def __init__(self): + super().__init__(intents=intents) - Returns: - Tuple of (channel, user_id) - """ - if ":" not in chat_id: - raise ValueError(f"Invalid chat_id format: {chat_id}") - channel, user_id = chat_id.split(":", 1) - return channel, user_id + async def on_ready(self): + logger.info(f"QQ bot ready: {self.robot.name}") + + async def on_c2c_message_create(self, message: "C2CMessage"): + await channel._on_message(message) + + async def on_direct_message_create(self, message): + await channel._on_message(message) + + return _Bot class QQChannel(BaseChannel): - """ - QQ channel using botpy SDK with WebSocket connection. - - Uses botpy SDK to connect to QQ Open Platform (q.qq.com). - - Requires: - - App ID and Secret from q.qq.com - - Robot capability enabled - """ + """QQ channel using botpy SDK with WebSocket connection.""" name = "qq" @@ -57,79 +54,43 @@ class QQChannel(BaseChannel): super().__init__(config, bus) self.config: QQConfig = config self._client: "botpy.Client | None" = None - self._processed_message_ids: deque = deque(maxlen=1000) + self._processed_ids: deque = deque(maxlen=1000) self._bot_task: asyncio.Task | None = None async def start(self) -> None: """Start the QQ bot.""" if not QQ_AVAILABLE: - logger.error("QQ SDK ๆœชๅฎ‰่ฃ…ใ€‚่ฏท่ฟ่กŒ๏ผšpip install qq-botpy") + logger.error("QQ SDK not installed. Run: pip install qq-botpy") return if not self.config.app_id or not self.config.secret: - logger.error("QQ app_id ๅ’Œ secret ๆœช้…็ฝฎ") + logger.error("QQ app_id and secret not configured") return self._running = True + BotClass = _make_bot_class(self) + self._client = BotClass() - # Create bot client with C2C intents - intents = botpy.Intents.all() - logger.info(f"QQ Intents ้…็ฝฎๅ€ผ: {intents.value}") + self._bot_task = asyncio.create_task(self._run_bot()) + logger.info("QQ bot started (C2C private message)") - # Create custom bot class with message handlers - class QQBot(botpy.Client): - def __init__(self, channel): - super().__init__(intents=intents) - self.channel = channel - - async def on_ready(self): - """Called when bot is ready.""" - logger.info(f"QQ bot ready: {self.robot.name}") - - async def on_c2c_message_create(self, message: "C2CMessage"): - """Handle C2C (Client to Client) messages - private chat.""" - await self.channel._on_message(message, "c2c") - - async def on_direct_message_create(self, message): - """Handle direct messages - alternative event name.""" - await self.channel._on_message(message, "direct") - - # TODO: Group message support - implement in future PRD - # async def on_group_at_message_create(self, message): - # """Handle group @ messages.""" - # pass - - self._client = QQBot(self) - - # Start bot - use create_task to run concurrently - self._bot_task = asyncio.create_task( - self._run_bot_with_retry(self.config.app_id, self.config.secret) - ) - - logger.info("QQ bot started with C2C (private message) support") - - async def _run_bot_with_retry(self, app_id: str, secret: str) -> None: - """Run bot with error handling.""" + async def _run_bot(self) -> None: + """Run the bot connection.""" try: - await self._client.start(appid=app_id, secret=secret) + await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.error( - f"QQ ้‰ดๆƒๅคฑ่ดฅ๏ผŒ่ฏทๆฃ€ๆŸฅ AppID ๅ’Œ Secret ๆ˜ฏๅฆๆญฃ็กฎใ€‚" - f"่ฎฟ้—ฎ q.qq.com ่Žทๅ–ๅ‡ญ่ฏใ€‚้”™่ฏฏ: {e}" - ) + logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") self._running = False async def stop(self) -> None: """Stop the QQ bot.""" self._running = False - if self._bot_task: self._bot_task.cancel() try: await self._bot_task except asyncio.CancelledError: pass - logger.info("QQ bot stopped") async def send(self, msg: OutboundMessage) -> None: @@ -137,75 +98,34 @@ class QQChannel(BaseChannel): if not self._client: logger.warning("QQ client not initialized") return - try: - # Parse chat_id format: qq:{user_id} - channel, user_id = parse_chat_id(msg.chat_id) - - if channel != "qq": - logger.warning(f"Invalid channel in chat_id: {msg.chat_id}") - return - - # Send private message using botpy API await self._client.api.post_c2c_message( - openid=user_id, + openid=msg.chat_id, msg_type=0, content=msg.content, ) - logger.debug(f"QQ message sent to {msg.chat_id}") - - except ValueError as e: - logger.error(f"Invalid chat_id format: {e}") except Exception as e: logger.error(f"Error sending QQ message: {e}") - async def _on_message(self, data: "C2CMessage", msg_type: str) -> None: + async def _on_message(self, data: "C2CMessage") -> None: """Handle incoming message from QQ.""" try: - # Message deduplication using deque with maxlen - message_id = data.id - if message_id in self._processed_message_ids: - logger.debug(f"Duplicate message {message_id}, skipping") + # Dedup by message ID + if data.id in self._processed_ids: return + self._processed_ids.append(data.id) - self._processed_message_ids.append(message_id) - - # Extract user ID and chat ID from message author = data.author - # Try different possible field names for user ID user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) - user_name = getattr(author, 'username', None) or 'unknown' - - # For C2C messages, chat_id is the user's ID - chat_id = f"qq:{user_id}" - - # Check allow_from list (if configured) - if self.config.allow_from and user_id not in self.config.allow_from: - logger.info(f"User {user_id} not in allow_from list") - return - - # Get message content - content = data.content or "" - + content = (data.content or "").strip() if not content: - logger.debug(f"Empty message from {user_id}, skipping") return - # Publish to message bus - msg = InboundMessage( - channel=self.name, + await self._handle_message( sender_id=user_id, - chat_id=chat_id, + chat_id=user_id, content=content, - metadata={ - "message_id": message_id, - "user_name": user_name, - "msg_type": msg_type, - }, + metadata={"message_id": data.id}, ) - await self.bus.publish_inbound(msg) - - logger.info(f"Received QQ message from {user_id} ({msg_type}): {content[:50]}") - except Exception as e: logger.error(f"Error handling QQ message: {e}") From 1e95f8b486771b99708dbafb94f236939149acba Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 12:07:45 +0000 Subject: [PATCH 40/58] docs: add 9 feb news --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6cde257..d5a1e17 100644 --- a/README.md +++ b/README.md @@ -16,10 +16,11 @@ โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. -๐Ÿ“ Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime) ## ๐Ÿ“ข News +- **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. - **2026-02-06** โœจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening! From 03d3c69a4ad0f9181965808798d45c52f9126072 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 12:40:24 +0000 Subject: [PATCH 41/58] docs: improve Email channel setup guide --- README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index d5a1e17..7e1f80b 100644 --- a/README.md +++ b/README.md @@ -459,17 +459,19 @@ nanobot gateway
Email -Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data. +Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** โ€” like a personal email assistant. **1. Get credentials (Gmail example)** -- Enable 2-Step Verification in Google account security -- Create an [App Password](https://myaccount.google.com/apppasswords) +- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`) +- Enable 2-Step Verification โ†’ Create an [App Password](https://myaccount.google.com/apppasswords) - Use this app password for both IMAP and SMTP **2. Configure** -> [!TIP] -> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. +> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate โ€” set `false` to fully disable. +> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders. +> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly. +> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies. ```json { @@ -479,23 +481,19 @@ Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit con "consentGranted": true, "imapHost": "imap.gmail.com", "imapPort": 993, - "imapUsername": "you@gmail.com", + "imapUsername": "my-nanobot@gmail.com", "imapPassword": "your-app-password", - "imapUseSsl": true, "smtpHost": "smtp.gmail.com", "smtpPort": 587, - "smtpUsername": "you@gmail.com", + "smtpUsername": "my-nanobot@gmail.com", "smtpPassword": "your-app-password", - "smtpUseTls": true, - "fromAddress": "you@gmail.com", - "allowFrom": ["trusted@example.com"] + "fromAddress": "my-nanobot@gmail.com", + "allowFrom": ["your-real-email@gmail.com"] } } } ``` -> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely. -> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses. **3. Run** From 4f928e9d2a27879b95b1f16286a7aeeab27feed1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 16:17:35 +0000 Subject: [PATCH 42/58] feat: improve QQ channel setup guide and fix botpy intent flags --- README.md | 28 ++++++++++++++++------------ nanobot/channels/qq.py | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 7e1f80b..eb2ff7f 100644 --- a/README.md +++ b/README.md @@ -341,16 +341,24 @@ nanobot gateway
-QQ (QQ็ง่Š) +QQ (QQๅ•่Š) -Uses **botpy SDK** with WebSocket โ€” no public IP required. +Uses **botpy SDK** with WebSocket โ€” no public IP required. Currently supports **private messages only**. -**1. Create a QQ bot** -- Visit [QQ Open Platform](https://q.qq.com) +**1. Register & create bot** +- Visit [QQ Open Platform](https://q.qq.com) โ†’ Register as a developer (personal or enterprise) - Create a new bot application -- Get **AppID** and **Secret** from "Developer Settings" +- Go to **ๅผ€ๅ‘่ฎพ็ฝฎ (Developer Settings)** โ†’ copy **AppID** and **AppSecret** -**2. Configure** +**2. Set up sandbox for testing** +- In the bot management console, find **ๆฒ™็ฎฑ้…็ฝฎ (Sandbox Config)** +- Under **ๅœจๆถˆๆฏๅˆ—่กจ้…็ฝฎ**, click **ๆทปๅŠ ๆˆๅ‘˜** and add your own QQ number +- Once added, scan the bot's QR code with mobile QQ โ†’ open the bot profile โ†’ tap "ๅ‘ๆถˆๆฏ" to start chatting + +**3. Configure** + +> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot. +> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow. ```json { @@ -365,17 +373,13 @@ Uses **botpy SDK** with WebSocket โ€” no public IP required. } ``` -> `allowFrom`: Leave empty for public access, or add user openids to restrict access. -> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]` - -**3. Run** +**4. Run** ```bash nanobot gateway ``` -> [!TIP] -> QQ bot currently supports **private messages only**. Group chat support coming soon! +Now send a message to the bot from QQ โ€” it should respond!
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index e3efb4f..5964d30 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": """Create a botpy Client subclass bound to the given channel.""" - intents = botpy.Intents(c2c_message=True) + intents = botpy.Intents(public_messages=True, direct_message=True) class _Bot(botpy.Client): def __init__(self): From ec4340d0d8d2c6667d7977f87cc7bb3f3ffd5b62 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Feb 2026 16:49:13 +0000 Subject: [PATCH 43/58] feat: add App Home step to Slack guide, default groupPolicy to mention --- README.md | 27 +++++++++++++++++---------- nanobot/config/schema.py | 2 +- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index eb2ff7f..2a6d29d 100644 --- a/README.md +++ b/README.md @@ -428,13 +428,17 @@ nanobot gateway Uses **Socket Mode** โ€” no public URL required. **1. Create a Slack app** -- Go to [Slack API](https://api.slack.com/apps) โ†’ Create New App -- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read` -- Install to your workspace and copy the **Bot Token** (`xoxb-...`) -- **Socket Mode**: Enable it and generate an **App-Level Token** (`xapp-...`) with `connections:write` scope -- **Event Subscriptions**: Subscribe to `message.im`, `message.channels`, `app_mention` +- Go to [Slack API](https://api.slack.com/apps) โ†’ **Create New App** โ†’ "From scratch" +- Pick a name and select your workspace -**2. Configure** +**2. Configure the app** +- **Socket Mode**: Toggle ON โ†’ Generate an **App-Level Token** with `connections:write` scope โ†’ copy it (`xapp-...`) +- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read` +- **Event Subscriptions**: Toggle ON โ†’ Subscribe to bot events: `message.im`, `message.channels`, `app_mention` โ†’ Save Changes +- **App Home**: Scroll to **Show Tabs** โ†’ Enable **Messages Tab** โ†’ Check **"Allow users to send Slash commands and messages from the messages tab"** +- **Install App**: Click **Install to Workspace** โ†’ Authorize โ†’ copy the **Bot Token** (`xoxb-...`) + +**3. Configure nanobot** ```json { @@ -449,15 +453,18 @@ Uses **Socket Mode** โ€” no public URL required. } ``` -> `groupPolicy`: `"mention"` (respond only when @mentioned), `"open"` (respond to all messages), or `"allowlist"` (restrict to specific channels). -> DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. - -**3. Run** +**4. Run** ```bash nanobot gateway ``` +DM the bot directly or @mention it in a channel โ€” it should respond! + +> [!TIP] +> - `groupPolicy`: `"mention"` (default โ€” respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels). +> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. +
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1aae587..fe0259e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -92,7 +92,7 @@ class SlackConfig(BaseModel): bot_token: str = "" # xoxb-... app_token: str = "" # xapp-... user_token_read_only: bool = True - group_policy: str = "open" # "open", "mention", "allowlist" + group_policy: str = "mention" # "mention", "open", "allowlist" group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist dm: SlackDMConfig = Field(default_factory=SlackDMConfig) From fba5345d20b793765582a4f94db3a0e9813349a7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 02:09:31 +0000 Subject: [PATCH 44/58] fix: pass api_key directly to litellm for more robust auth --- nanobot/providers/litellm_provider.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 9d76c2a..dd50ed9 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -132,6 +132,10 @@ class LiteLLMProvider(LLMProvider): # Apply model-specific overrides (e.g. kimi-k2.5 temperature) self._apply_model_overrides(model, kwargs) + # Pass api_key directly โ€” more reliable than env vars alone + if self.api_key: + kwargs["api_key"] = self.api_key + # Pass api_base for custom endpoints if self.api_base: kwargs["api_base"] = self.api_base From fc9dc4b39718860966f3772944a8a994b4bbe40e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 03:00:42 +0000 Subject: [PATCH 45/58] Release v0.1.3.post6 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8662f58..63e148d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.3.post5" +version = "0.1.3.post6" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 76e51ca8def96567aef2c893e9c226d92bdc8ba5 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 03:07:27 +0000 Subject: [PATCH 46/58] docs: release v0.1.3.post6 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2a6d29d..21c8613 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## ๐Ÿ“ข News +- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap discussion](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. @@ -689,7 +690,7 @@ PRs welcome! The codebase is intentionally small and readable. ๐Ÿค— ### Contributors - + Contributors From a779f8c453299891415ee37924f7c6757ab8f03f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 03:08:17 +0000 Subject: [PATCH 47/58] docs: update release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21c8613..8503b6c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap discussion](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. From f634658707ccc1bad59276f8d484bc3ca9040346 Mon Sep 17 00:00:00 2001 From: ouyangwulin Date: Tue, 10 Feb 2026 11:10:00 +0800 Subject: [PATCH 48/58] fixed dingtalk exception. --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 8662f58..6413c47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dependencies = [ "socksio>=1.0.0", "slack-sdk>=3.26.0", "qq-botpy>=1.0.0", + "python-socks[asyncio]>=2.4.0", ] [project.optional-dependencies] From ba2bdb080de75079b5457fb98bd39aef3797b13b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 07:06:04 +0000 Subject: [PATCH 49/58] refactor: streamline mochat channel --- nanobot/channels/__init__.py | 3 +- nanobot/channels/mochat.py | 920 +++++++++++------------------------ 2 files changed, 295 insertions(+), 628 deletions(-) diff --git a/nanobot/channels/__init__.py b/nanobot/channels/__init__.py index 034d401..588169d 100644 --- a/nanobot/channels/__init__.py +++ b/nanobot/channels/__init__.py @@ -2,6 +2,5 @@ from nanobot.channels.base import BaseChannel from nanobot.channels.manager import ChannelManager -from nanobot.channels.mochat import MochatChannel -__all__ = ["BaseChannel", "ChannelManager", "MochatChannel"] +__all__ = ["BaseChannel", "ChannelManager"] diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 6569cdd..30c3dbf 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -20,7 +20,6 @@ from nanobot.utils.helpers import get_data_path try: import socketio - SOCKETIO_AVAILABLE = True except ImportError: socketio = None @@ -28,20 +27,21 @@ except ImportError: try: import msgpack # noqa: F401 - MSGPACK_AVAILABLE = True except ImportError: MSGPACK_AVAILABLE = False - MAX_SEEN_MESSAGE_IDS = 2000 CURSOR_SAVE_DEBOUNCE_S = 0.5 +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + @dataclass class MochatBufferedEntry: """Buffered inbound entry for delayed dispatch.""" - raw_body: str author: str sender_name: str = "" @@ -54,7 +54,6 @@ class MochatBufferedEntry: @dataclass class DelayState: """Per-target delayed message state.""" - entries: list[MochatBufferedEntry] = field(default_factory=list) lock: asyncio.Lock = field(default_factory=asyncio.Lock) timer: asyncio.Task | None = None @@ -63,11 +62,48 @@ class DelayState: @dataclass class MochatTarget: """Outbound target resolution result.""" - id: str is_panel: bool +# --------------------------------------------------------------------------- +# Pure helpers +# --------------------------------------------------------------------------- + +def _safe_dict(value: Any) -> dict: + """Return *value* if it's a dict, else empty dict.""" + return value if isinstance(value, dict) else {} + + +def _str_field(src: dict, *keys: str) -> str: + """Return the first non-empty str value found for *keys*, stripped.""" + for k in keys: + v = src.get(k) + if isinstance(v, str) and v.strip(): + return v.strip() + return "" + + +def _make_synthetic_event( + message_id: str, author: str, content: Any, + meta: Any, group_id: str, converse_id: str, + timestamp: Any = None, *, author_info: Any = None, +) -> dict[str, Any]: + """Build a synthetic ``message.add`` event dict.""" + payload: dict[str, Any] = { + "messageId": message_id, "author": author, + "content": content, "meta": _safe_dict(meta), + "groupId": group_id, "converseId": converse_id, + } + if author_info is not None: + payload["authorInfo"] = _safe_dict(author_info) + return { + "type": "message.add", + "timestamp": timestamp or datetime.utcnow().isoformat(), + "payload": payload, + } + + def normalize_mochat_content(content: Any) -> str: """Normalize content payload to text.""" if isinstance(content, str): @@ -87,20 +123,15 @@ def resolve_mochat_target(raw: str) -> MochatTarget: return MochatTarget(id="", is_panel=False) lowered = trimmed.lower() - cleaned = trimmed - forced_panel = False - - prefixes = ["mochat:", "group:", "channel:", "panel:"] - for prefix in prefixes: + cleaned, forced_panel = trimmed, False + for prefix in ("mochat:", "group:", "channel:", "panel:"): if lowered.startswith(prefix): - cleaned = trimmed[len(prefix) :].strip() - if prefix in {"group:", "channel:", "panel:"}: - forced_panel = True + cleaned = trimmed[len(prefix):].strip() + forced_panel = prefix in {"group:", "channel:", "panel:"} break if not cleaned: return MochatTarget(id="", is_panel=False) - return MochatTarget(id=cleaned, is_panel=forced_panel or not cleaned.startswith("session_")) @@ -108,24 +139,17 @@ def extract_mention_ids(value: Any) -> list[str]: """Extract mention ids from heterogeneous mention payload.""" if not isinstance(value, list): return [] - ids: list[str] = [] for item in value: if isinstance(item, str): - text = item.strip() - if text: - ids.append(text) - continue - - if not isinstance(item, dict): - continue - - for key in ("id", "userId", "_id"): - candidate = item.get(key) - if isinstance(candidate, str) and candidate.strip(): - ids.append(candidate.strip()) - break - + if item.strip(): + ids.append(item.strip()) + elif isinstance(item, dict): + for key in ("id", "userId", "_id"): + candidate = item.get(key) + if isinstance(candidate, str) and candidate.strip(): + ids.append(candidate.strip()) + break return ids @@ -135,35 +159,23 @@ def resolve_was_mentioned(payload: dict[str, Any], agent_user_id: str) -> bool: if isinstance(meta, dict): if meta.get("mentioned") is True or meta.get("wasMentioned") is True: return True - - for field in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"): - ids = extract_mention_ids(meta.get(field)) - if agent_user_id and agent_user_id in ids: + for f in ("mentions", "mentionIds", "mentionedUserIds", "mentionedUsers"): + if agent_user_id and agent_user_id in extract_mention_ids(meta.get(f)): return True - if not agent_user_id: return False - content = payload.get("content") if not isinstance(content, str) or not content: return False - return f"<@{agent_user_id}>" in content or f"@{agent_user_id}" in content -def resolve_require_mention( - config: MochatConfig, - session_id: str, - group_id: str, -) -> bool: +def resolve_require_mention(config: MochatConfig, session_id: str, group_id: str) -> bool: """Resolve mention requirement for group/panel conversations.""" groups = config.groups or {} - if group_id and group_id in groups: - return bool(groups[group_id].require_mention) - if session_id in groups: - return bool(groups[session_id].require_mention) - if "*" in groups: - return bool(groups["*"].require_mention) + for key in (group_id, session_id, "*"): + if key and key in groups: + return bool(groups[key].require_mention) return bool(config.mention.require_in_groups) @@ -171,22 +183,18 @@ def build_buffered_body(entries: list[MochatBufferedEntry], is_group: bool) -> s """Build text body from one or more buffered entries.""" if not entries: return "" - if len(entries) == 1: return entries[0].raw_body - lines: list[str] = [] for entry in entries: - body = entry.raw_body - if not body: + if not entry.raw_body: continue if is_group: label = entry.sender_name.strip() or entry.sender_username.strip() or entry.author if label: - lines.append(f"{label}: {body}") + lines.append(f"{label}: {entry.raw_body}") continue - lines.append(body) - + lines.append(entry.raw_body) return "\n".join(lines).strip() @@ -200,6 +208,10 @@ def parse_timestamp(value: Any) -> int | None: return None +# --------------------------------------------------------------------------- +# Channel +# --------------------------------------------------------------------------- + class MochatChannel(BaseChannel): """Mochat channel using socket.io with fallback polling workers.""" @@ -210,8 +222,7 @@ class MochatChannel(BaseChannel): self.config: MochatConfig = config self._http: httpx.AsyncClient | None = None self._socket: Any = None - self._ws_connected = False - self._ws_ready = False + self._ws_connected = self._ws_ready = False self._state_dir = get_data_path() / "mochat" self._cursor_path = self._state_dir / "session_cursors.json" @@ -220,24 +231,23 @@ class MochatChannel(BaseChannel): self._session_set: set[str] = set() self._panel_set: set[str] = set() - self._auto_discover_sessions = False - self._auto_discover_panels = False + self._auto_discover_sessions = self._auto_discover_panels = False self._cold_sessions: set[str] = set() self._session_by_converse: dict[str, str] = {} self._seen_set: dict[str, set[str]] = {} self._seen_queue: dict[str, deque[str]] = {} - self._delay_states: dict[str, DelayState] = {} self._fallback_mode = False self._session_fallback_tasks: dict[str, asyncio.Task] = {} self._panel_fallback_tasks: dict[str, asyncio.Task] = {} self._refresh_task: asyncio.Task | None = None - self._target_locks: dict[str, asyncio.Lock] = {} + # ---- lifecycle --------------------------------------------------------- + async def start(self) -> None: """Start Mochat channel workers and websocket connection.""" if not self.config.claw_token: @@ -246,26 +256,21 @@ class MochatChannel(BaseChannel): self._running = True self._http = httpx.AsyncClient(timeout=30.0) - self._state_dir.mkdir(parents=True, exist_ok=True) await self._load_session_cursors() self._seed_targets_from_config() - await self._refresh_targets(subscribe_new=False) - websocket_started = await self._start_socket_client() - if not websocket_started: + if not await self._start_socket_client(): await self._ensure_fallback_workers() self._refresh_task = asyncio.create_task(self._refresh_loop()) - while self._running: await asyncio.sleep(1) async def stop(self) -> None: """Stop all workers and clean up resources.""" self._running = False - if self._refresh_task: self._refresh_task.cancel() self._refresh_task = None @@ -283,15 +288,12 @@ class MochatChannel(BaseChannel): if self._cursor_save_task: self._cursor_save_task.cancel() self._cursor_save_task = None - await self._save_session_cursors() if self._http: await self._http.aclose() self._http = None - - self._ws_connected = False - self._ws_ready = False + self._ws_connected = self._ws_ready = False async def send(self, msg: OutboundMessage) -> None: """Send outbound message to session or panel.""" @@ -299,10 +301,10 @@ class MochatChannel(BaseChannel): logger.warning("Mochat claw_token missing, skip send") return - content_parts = [msg.content.strip()] if msg.content and msg.content.strip() else [] + parts = ([msg.content.strip()] if msg.content and msg.content.strip() else []) if msg.media: - content_parts.extend([m for m in msg.media if isinstance(m, str) and m.strip()]) - content = "\n".join(content_parts).strip() + parts.extend(m for m in msg.media if isinstance(m, str) and m.strip()) + content = "\n".join(parts).strip() if not content: return @@ -311,43 +313,34 @@ class MochatChannel(BaseChannel): logger.warning("Mochat outbound target is empty") return - is_panel = target.is_panel or target.id in self._panel_set - if target.id.startswith("session_"): - is_panel = False - + is_panel = (target.is_panel or target.id in self._panel_set) and not target.id.startswith("session_") try: if is_panel: - await self._send_panel_message( - panel_id=target.id, - content=content, - reply_to=msg.reply_to, - group_id=self._read_group_id(msg.metadata), - ) + await self._api_send("/api/claw/groups/panels/send", "panelId", target.id, + content, msg.reply_to, self._read_group_id(msg.metadata)) else: - await self._send_session_message( - session_id=target.id, - content=content, - reply_to=msg.reply_to, - ) + await self._api_send("/api/claw/sessions/send", "sessionId", target.id, + content, msg.reply_to) except Exception as e: logger.error(f"Failed to send Mochat message: {e}") + # ---- config / init helpers --------------------------------------------- + def _seed_targets_from_config(self) -> None: sessions, self._auto_discover_sessions = self._normalize_id_list(self.config.sessions) panels, self._auto_discover_panels = self._normalize_id_list(self.config.panels) - self._session_set.update(sessions) self._panel_set.update(panels) + for sid in sessions: + if sid not in self._session_cursor: + self._cold_sessions.add(sid) - for session_id in sessions: - if session_id not in self._session_cursor: - self._cold_sessions.add(session_id) - - def _normalize_id_list(self, values: list[str]) -> tuple[list[str], bool]: + @staticmethod + def _normalize_id_list(values: list[str]) -> tuple[list[str], bool]: cleaned = [str(v).strip() for v in values if str(v).strip()] - has_wildcard = "*" in cleaned - ids = sorted({v for v in cleaned if v != "*"}) - return ids, has_wildcard + return sorted({v for v in cleaned if v != "*"}), "*" in cleaned + + # ---- websocket --------------------------------------------------------- async def _start_socket_client(self) -> bool: if not SOCKETIO_AVAILABLE: @@ -359,83 +352,56 @@ class MochatChannel(BaseChannel): if MSGPACK_AVAILABLE: serializer = "msgpack" else: - logger.warning( - "msgpack is not installed but socket_disable_msgpack=false; " - "trying JSON serializer" - ) - - reconnect_attempts = None - if self.config.max_retry_attempts > 0: - reconnect_attempts = self.config.max_retry_attempts + logger.warning("msgpack not installed but socket_disable_msgpack=false; using JSON") client = socketio.AsyncClient( reconnection=True, - reconnection_attempts=reconnect_attempts, + reconnection_attempts=self.config.max_retry_attempts or None, reconnection_delay=max(0.1, self.config.socket_reconnect_delay_ms / 1000.0), - reconnection_delay_max=max( - 0.1, - self.config.socket_max_reconnect_delay_ms / 1000.0, - ), - logger=False, - engineio_logger=False, - serializer=serializer, + reconnection_delay_max=max(0.1, self.config.socket_max_reconnect_delay_ms / 1000.0), + logger=False, engineio_logger=False, serializer=serializer, ) @client.event async def connect() -> None: - self._ws_connected = True - self._ws_ready = False + self._ws_connected, self._ws_ready = True, False logger.info("Mochat websocket connected") - subscribed = await self._subscribe_all() self._ws_ready = subscribed - if subscribed: - await self._stop_fallback_workers() - else: - await self._ensure_fallback_workers() + await (self._stop_fallback_workers() if subscribed else self._ensure_fallback_workers()) @client.event async def disconnect() -> None: if not self._running: return - self._ws_connected = False - self._ws_ready = False + self._ws_connected = self._ws_ready = False 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"Mochat websocket connect error: {message}") + logger.error(f"Mochat websocket connect error: {data}") @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: - await self._handle_watch_payload(payload, target_kind="session") + await self._handle_watch_payload(payload, "session") @client.on("claw.panel.events") async def on_panel_events(payload: dict[str, Any]) -> None: - await self._handle_watch_payload(payload, target_kind="panel") + await self._handle_watch_payload(payload, "panel") - for event_name in ( - "notify:chat.inbox.append", - "notify:chat.message.add", - "notify:chat.message.update", - "notify:chat.message.recall", - "notify:chat.message.delete", - ): - client.on(event_name, self._build_notify_handler(event_name)) + for ev in ("notify:chat.inbox.append", "notify:chat.message.add", + "notify:chat.message.update", "notify:chat.message.recall", + "notify:chat.message.delete"): + client.on(ev, self._build_notify_handler(ev)) socket_url = (self.config.socket_url or self.config.base_url).strip().rstrip("/") - socket_path = (self.config.socket_path or "/socket.io").strip() - if socket_path.startswith("/"): - socket_path = socket_path[1:] + socket_path = (self.config.socket_path or "/socket.io").strip().lstrip("/") try: self._socket = client await client.connect( - socket_url, - transports=["websocket"], - socketio_path=socket_path, + socket_url, transports=["websocket"], socketio_path=socket_path, auth={"token": self.config.claw_token}, wait_timeout=max(1.0, self.config.socket_connect_timeout_ms / 1000.0), ) @@ -453,38 +419,30 @@ class MochatChannel(BaseChannel): async def handler(payload: Any) -> None: if event_name == "notify:chat.inbox.append": await self._handle_notify_inbox_append(payload) - return - - if event_name.startswith("notify:chat.message."): + elif event_name.startswith("notify:chat.message."): await self._handle_notify_chat_message(payload) - return handler - async def _subscribe_all(self) -> bool: - sessions_ok = await self._subscribe_sessions(sorted(self._session_set)) - panels_ok = await self._subscribe_panels(sorted(self._panel_set)) + # ---- subscribe --------------------------------------------------------- + async def _subscribe_all(self) -> bool: + ok = await self._subscribe_sessions(sorted(self._session_set)) + ok = await self._subscribe_panels(sorted(self._panel_set)) and ok if self._auto_discover_sessions or self._auto_discover_panels: await self._refresh_targets(subscribe_new=True) - - return sessions_ok and panels_ok + return ok async def _subscribe_sessions(self, session_ids: list[str]) -> bool: if not session_ids: return True + for sid in session_ids: + if sid not in self._session_cursor: + self._cold_sessions.add(sid) - for session_id in session_ids: - if session_id not in self._session_cursor: - self._cold_sessions.add(session_id) - - ack = await self._socket_call( - "com.claw.im.subscribeSessions", - { - "sessionIds": session_ids, - "cursors": self._session_cursor, - "limit": self.config.watch_limit, - }, - ) + ack = await self._socket_call("com.claw.im.subscribeSessions", { + "sessionIds": session_ids, "cursors": self._session_cursor, + "limit": self.config.watch_limit, + }) if not ack.get("result"): logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}") return False @@ -492,73 +450,57 @@ class MochatChannel(BaseChannel): data = ack.get("data") items: list[dict[str, Any]] = [] if isinstance(data, list): - items = [item for item in data if isinstance(item, dict)] + items = [i for i in data if isinstance(i, dict)] elif isinstance(data, dict): sessions = data.get("sessions") if isinstance(sessions, list): - items = [item for item in sessions if isinstance(item, dict)] + items = [i for i in sessions if isinstance(i, dict)] elif "sessionId" in data: items = [data] - - for payload in items: - await self._handle_watch_payload(payload, target_kind="session") - + for p in items: + await self._handle_watch_payload(p, "session") return True async def _subscribe_panels(self, panel_ids: list[str]) -> bool: if not self._auto_discover_panels and not panel_ids: return True - - ack = await self._socket_call( - "com.claw.im.subscribePanels", - { - "panelIds": panel_ids, - }, - ) + ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) if not ack.get("result"): logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}") return False - return True async def _socket_call(self, event_name: str, payload: dict[str, Any]) -> dict[str, Any]: if not self._socket: return {"result": False, "message": "socket not connected"} - try: raw = await self._socket.call(event_name, payload, timeout=10) except Exception as e: return {"result": False, "message": str(e)} + return raw if isinstance(raw, dict) else {"result": True, "data": raw} - if isinstance(raw, dict): - return raw - - return {"result": True, "data": raw} + # ---- refresh / discovery ----------------------------------------------- async def _refresh_loop(self) -> None: interval_s = max(1.0, self.config.refresh_interval_ms / 1000.0) - while self._running: await asyncio.sleep(interval_s) - try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: logger.warning(f"Mochat refresh failed: {e}") - if self._fallback_mode: await self._ensure_fallback_workers() async def _refresh_targets(self, subscribe_new: bool) -> None: if self._auto_discover_sessions: - await self._refresh_sessions_directory(subscribe_new=subscribe_new) - + await self._refresh_sessions_directory(subscribe_new) if self._auto_discover_panels: - await self._refresh_panels(subscribe_new=subscribe_new) + await self._refresh_panels(subscribe_new) async def _refresh_sessions_directory(self, subscribe_new: bool) -> None: try: - response = await self._list_sessions() + response = await self._post_json("/api/claw/sessions/list", {}) except Exception as e: logger.warning(f"Mochat listSessions failed: {e}") return @@ -567,37 +509,32 @@ class MochatChannel(BaseChannel): if not isinstance(sessions, list): return - new_sessions: list[str] = [] - for session in sessions: - if not isinstance(session, dict): + new_ids: list[str] = [] + for s in sessions: + if not isinstance(s, dict): continue - - session_id = str(session.get("sessionId") or "").strip() - if not session_id: + sid = _str_field(s, "sessionId") + if not sid: continue + if sid not in self._session_set: + self._session_set.add(sid) + new_ids.append(sid) + if sid not in self._session_cursor: + self._cold_sessions.add(sid) + cid = _str_field(s, "converseId") + if cid: + self._session_by_converse[cid] = sid - if session_id not in self._session_set: - self._session_set.add(session_id) - new_sessions.append(session_id) - if session_id not in self._session_cursor: - self._cold_sessions.add(session_id) - - converse_id = str(session.get("converseId") or "").strip() - if converse_id: - self._session_by_converse[converse_id] = session_id - - if not new_sessions: + if not new_ids: return - if self._ws_ready and subscribe_new: - await self._subscribe_sessions(new_sessions) - + await self._subscribe_sessions(new_ids) if self._fallback_mode: await self._ensure_fallback_workers() async def _refresh_panels(self, subscribe_new: bool) -> None: try: - response = await self._get_workspace_group() + response = await self._post_json("/api/claw/groups/get", {}) except Exception as e: logger.warning(f"Mochat getWorkspaceGroup failed: {e}") return @@ -606,80 +543,58 @@ class MochatChannel(BaseChannel): if not isinstance(raw_panels, list): return - new_panels: list[str] = [] - for panel in raw_panels: - if not isinstance(panel, dict): + new_ids: list[str] = [] + for p in raw_panels: + if not isinstance(p, dict): continue - - panel_type = panel.get("type") - if isinstance(panel_type, int) and panel_type != 0: + pt = p.get("type") + if isinstance(pt, int) and pt != 0: continue + pid = _str_field(p, "id", "_id") + if pid and pid not in self._panel_set: + self._panel_set.add(pid) + new_ids.append(pid) - panel_id = str(panel.get("id") or panel.get("_id") or "").strip() - if not panel_id: - continue - - if panel_id not in self._panel_set: - self._panel_set.add(panel_id) - new_panels.append(panel_id) - - if not new_panels: + if not new_ids: return - if self._ws_ready and subscribe_new: - await self._subscribe_panels(new_panels) - + await self._subscribe_panels(new_ids) if self._fallback_mode: await self._ensure_fallback_workers() + # ---- fallback workers -------------------------------------------------- + async def _ensure_fallback_workers(self) -> None: if not self._running: return - self._fallback_mode = True - - for session_id in sorted(self._session_set): - task = self._session_fallback_tasks.get(session_id) - if task and not task.done(): - continue - self._session_fallback_tasks[session_id] = asyncio.create_task( - self._session_watch_worker(session_id) - ) - - for panel_id in sorted(self._panel_set): - task = self._panel_fallback_tasks.get(panel_id) - if task and not task.done(): - continue - self._panel_fallback_tasks[panel_id] = asyncio.create_task( - self._panel_poll_worker(panel_id) - ) + for sid in sorted(self._session_set): + t = self._session_fallback_tasks.get(sid) + if not t or t.done(): + self._session_fallback_tasks[sid] = asyncio.create_task(self._session_watch_worker(sid)) + for pid in sorted(self._panel_set): + t = self._panel_fallback_tasks.get(pid) + if not t or t.done(): + self._panel_fallback_tasks[pid] = asyncio.create_task(self._panel_poll_worker(pid)) async def _stop_fallback_workers(self) -> None: self._fallback_mode = False - - tasks = [ - *self._session_fallback_tasks.values(), - *self._panel_fallback_tasks.values(), - ] - for task in tasks: - task.cancel() - + tasks = [*self._session_fallback_tasks.values(), *self._panel_fallback_tasks.values()] + for t in tasks: + t.cancel() if tasks: await asyncio.gather(*tasks, return_exceptions=True) - self._session_fallback_tasks.clear() self._panel_fallback_tasks.clear() async def _session_watch_worker(self, session_id: str) -> None: while self._running and self._fallback_mode: try: - payload = await self._watch_session( - session_id=session_id, - cursor=self._session_cursor.get(session_id, 0), - timeout_ms=self.config.watch_timeout_ms, - limit=self.config.watch_limit, - ) - await self._handle_watch_payload(payload, target_kind="session") + payload = await self._post_json("/api/claw/sessions/watch", { + "sessionId": session_id, "cursor": self._session_cursor.get(session_id, 0), + "timeoutMs": self.config.watch_timeout_ms, "limit": self.config.watch_limit, + }) + await self._handle_watch_payload(payload, "session") except asyncio.CancelledError: break except Exception as e: @@ -688,72 +603,50 @@ class MochatChannel(BaseChannel): async def _panel_poll_worker(self, panel_id: str) -> None: sleep_s = max(1.0, self.config.refresh_interval_ms / 1000.0) - while self._running and self._fallback_mode: try: - response = await self._list_panel_messages( - panel_id=panel_id, - limit=min(100, max(1, self.config.watch_limit)), - ) - - raw_messages = response.get("messages") - if isinstance(raw_messages, list): - for message in reversed(raw_messages): - if not isinstance(message, dict): + resp = await self._post_json("/api/claw/groups/panels/messages", { + "panelId": panel_id, "limit": min(100, max(1, self.config.watch_limit)), + }) + msgs = resp.get("messages") + if isinstance(msgs, list): + for m in reversed(msgs): + if not isinstance(m, dict): continue - - synthetic_event = { - "type": "message.add", - "timestamp": message.get("createdAt") or datetime.utcnow().isoformat(), - "payload": { - "messageId": str(message.get("messageId") or ""), - "author": str(message.get("author") or ""), - "authorInfo": message.get("authorInfo") if isinstance(message.get("authorInfo"), dict) else {}, - "content": message.get("content"), - "meta": message.get("meta") if isinstance(message.get("meta"), dict) else {}, - "groupId": str(response.get("groupId") or ""), - "converseId": panel_id, - }, - } - await self._process_inbound_event( - target_id=panel_id, - event=synthetic_event, - target_kind="panel", + evt = _make_synthetic_event( + message_id=str(m.get("messageId") or ""), + author=str(m.get("author") or ""), + content=m.get("content"), + meta=m.get("meta"), group_id=str(resp.get("groupId") or ""), + converse_id=panel_id, timestamp=m.get("createdAt"), + author_info=m.get("authorInfo"), ) + await self._process_inbound_event(panel_id, evt, "panel") except asyncio.CancelledError: break except Exception as e: logger.warning(f"Mochat panel polling error ({panel_id}): {e}") - await asyncio.sleep(sleep_s) - async def _handle_watch_payload( - self, - payload: dict[str, Any], - target_kind: str, - ) -> None: + # ---- inbound event processing ------------------------------------------ + + async def _handle_watch_payload(self, payload: dict[str, Any], target_kind: str) -> None: if not isinstance(payload, dict): return - - target_id = str(payload.get("sessionId") or "").strip() + target_id = _str_field(payload, "sessionId") if not target_id: return lock = self._target_locks.setdefault(f"{target_kind}:{target_id}", asyncio.Lock()) async with lock: - previous_cursor = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0 - payload_cursor = payload.get("cursor") - if ( - target_kind == "session" - and isinstance(payload_cursor, int) - and payload_cursor >= 0 - ): - self._mark_session_cursor(target_id, payload_cursor) + prev = self._session_cursor.get(target_id, 0) if target_kind == "session" else 0 + pc = payload.get("cursor") + if target_kind == "session" and isinstance(pc, int) and pc >= 0: + self._mark_session_cursor(target_id, pc) raw_events = payload.get("events") if not isinstance(raw_events, list): return - if target_kind == "session" and target_id in self._cold_sessions: self._cold_sessions.discard(target_id) return @@ -762,324 +655,176 @@ class MochatChannel(BaseChannel): if not isinstance(event, dict): continue seq = event.get("seq") - if ( - target_kind == "session" - and isinstance(seq, int) - and seq > self._session_cursor.get(target_id, previous_cursor) - ): + if target_kind == "session" and isinstance(seq, int) and seq > self._session_cursor.get(target_id, prev): self._mark_session_cursor(target_id, seq) + if event.get("type") == "message.add": + await self._process_inbound_event(target_id, event, target_kind) - if event.get("type") != "message.add": - continue - - await self._process_inbound_event( - target_id=target_id, - event=event, - target_kind=target_kind, - ) - - async def _process_inbound_event( - self, - target_id: str, - event: dict[str, Any], - target_kind: str, - ) -> None: + async def _process_inbound_event(self, target_id: str, event: dict[str, Any], target_kind: str) -> None: payload = event.get("payload") if not isinstance(payload, dict): return - author = str(payload.get("author") or "").strip() - if not author: + author = _str_field(payload, "author") + if not author or (self.config.agent_user_id and author == self.config.agent_user_id): return - - if self.config.agent_user_id and author == self.config.agent_user_id: - return - if not self.is_allowed(author): return - message_id = str(payload.get("messageId") or "").strip() + message_id = _str_field(payload, "messageId") seen_key = f"{target_kind}:{target_id}" if message_id and self._remember_message_id(seen_key, message_id): return - raw_body = normalize_mochat_content(payload.get("content")) - if not raw_body: - raw_body = "[empty message]" + raw_body = normalize_mochat_content(payload.get("content")) or "[empty message]" + ai = _safe_dict(payload.get("authorInfo")) + sender_name = _str_field(ai, "nickname", "email") + sender_username = _str_field(ai, "agentId") - author_info = payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {} - sender_name = str(author_info.get("nickname") or author_info.get("email") or "").strip() - sender_username = str(author_info.get("agentId") or "").strip() - - group_id = str(payload.get("groupId") or "").strip() + group_id = _str_field(payload, "groupId") is_group = bool(group_id) was_mentioned = resolve_was_mentioned(payload, self.config.agent_user_id) - - require_mention = ( - target_kind == "panel" - and is_group - and resolve_require_mention(self.config, target_id, group_id) - ) - + require_mention = target_kind == "panel" and is_group and resolve_require_mention(self.config, target_id, group_id) use_delay = target_kind == "panel" and self.config.reply_delay_mode == "non-mention" if require_mention and not was_mentioned and not use_delay: return entry = MochatBufferedEntry( - raw_body=raw_body, - author=author, - sender_name=sender_name, - sender_username=sender_username, - timestamp=parse_timestamp(event.get("timestamp")), - message_id=message_id, - group_id=group_id, + raw_body=raw_body, author=author, sender_name=sender_name, + sender_username=sender_username, timestamp=parse_timestamp(event.get("timestamp")), + message_id=message_id, group_id=group_id, ) if use_delay: - delay_key = f"{target_kind}:{target_id}" + delay_key = seen_key if was_mentioned: - await self._flush_delayed_entries( - key=delay_key, - target_id=target_id, - target_kind=target_kind, - reason="mention", - entry=entry, - ) + await self._flush_delayed_entries(delay_key, target_id, target_kind, "mention", entry) else: - await self._enqueue_delayed_entry( - key=delay_key, - target_id=target_id, - target_kind=target_kind, - entry=entry, - ) + await self._enqueue_delayed_entry(delay_key, target_id, target_kind, entry) return - await self._dispatch_entries( - target_id=target_id, - target_kind=target_kind, - entries=[entry], - was_mentioned=was_mentioned, - ) + await self._dispatch_entries(target_id, target_kind, [entry], was_mentioned) + + # ---- dedup / buffering ------------------------------------------------- def _remember_message_id(self, key: str, message_id: str) -> bool: seen_set = self._seen_set.setdefault(key, set()) seen_queue = self._seen_queue.setdefault(key, deque()) - if message_id in seen_set: return True - seen_set.add(message_id) seen_queue.append(message_id) - while len(seen_queue) > MAX_SEEN_MESSAGE_IDS: - removed = seen_queue.popleft() - seen_set.discard(removed) - + seen_set.discard(seen_queue.popleft()) return False - async def _enqueue_delayed_entry( - self, - key: str, - target_id: str, - target_kind: str, - entry: MochatBufferedEntry, - ) -> None: + async def _enqueue_delayed_entry(self, key: str, target_id: str, target_kind: str, entry: MochatBufferedEntry) -> None: state = self._delay_states.setdefault(key, DelayState()) - async with state.lock: state.entries.append(entry) if state.timer: state.timer.cancel() - - state.timer = asyncio.create_task( - self._delay_flush_after(key, target_id, target_kind) - ) + state.timer = asyncio.create_task(self._delay_flush_after(key, target_id, target_kind)) async def _delay_flush_after(self, key: str, target_id: str, target_kind: str) -> None: await asyncio.sleep(max(0, self.config.reply_delay_ms) / 1000.0) - await self._flush_delayed_entries( - key=key, - target_id=target_id, - target_kind=target_kind, - reason="timer", - entry=None, - ) + await self._flush_delayed_entries(key, target_id, target_kind, "timer", None) - async def _flush_delayed_entries( - self, - key: str, - target_id: str, - target_kind: str, - reason: str, - entry: MochatBufferedEntry | None, - ) -> None: + async def _flush_delayed_entries(self, key: str, target_id: str, target_kind: str, reason: str, entry: MochatBufferedEntry | None) -> None: state = self._delay_states.setdefault(key, DelayState()) - async with state.lock: if entry: state.entries.append(entry) - current = asyncio.current_task() if state.timer and state.timer is not current: state.timer.cancel() - state.timer = None - elif state.timer is current: - state.timer = None - + state.timer = None entries = state.entries[:] state.entries.clear() + if entries: + await self._dispatch_entries(target_id, target_kind, entries, reason == "mention") + async def _dispatch_entries(self, target_id: str, target_kind: str, entries: list[MochatBufferedEntry], was_mentioned: bool) -> None: if not entries: return - - await self._dispatch_entries( - target_id=target_id, - target_kind=target_kind, - entries=entries, - was_mentioned=(reason == "mention"), - ) - - async def _dispatch_entries( - self, - target_id: str, - target_kind: str, - entries: list[MochatBufferedEntry], - was_mentioned: bool, - ) -> None: - if not entries: - return - - is_group = bool(entries[-1].group_id) - body = build_buffered_body(entries, is_group) - if not body: - body = "[empty message]" - last = entries[-1] - metadata = { - "message_id": last.message_id, - "timestamp": last.timestamp, - "is_group": is_group, - "group_id": last.group_id, - "sender_name": last.sender_name, - "sender_username": last.sender_username, - "target_kind": target_kind, - "was_mentioned": was_mentioned, - "buffered_count": len(entries), - } - + is_group = bool(last.group_id) + body = build_buffered_body(entries, is_group) or "[empty message]" await self._handle_message( - sender_id=last.author, - chat_id=target_id, - content=body, - metadata=metadata, + sender_id=last.author, chat_id=target_id, content=body, + metadata={ + "message_id": last.message_id, "timestamp": last.timestamp, + "is_group": is_group, "group_id": last.group_id, + "sender_name": last.sender_name, "sender_username": last.sender_username, + "target_kind": target_kind, "was_mentioned": was_mentioned, + "buffered_count": len(entries), + }, ) async def _cancel_delay_timers(self) -> None: for state in self._delay_states.values(): if state.timer: state.timer.cancel() - state.timer = None self._delay_states.clear() + # ---- notify handlers --------------------------------------------------- + async def _handle_notify_chat_message(self, payload: Any) -> None: if not isinstance(payload, dict): return - - group_id = str(payload.get("groupId") or "").strip() - panel_id = str(payload.get("converseId") or payload.get("panelId") or "").strip() + group_id = _str_field(payload, "groupId") + panel_id = _str_field(payload, "converseId", "panelId") if not group_id or not panel_id: return - if self._panel_set and panel_id not in self._panel_set: return - synthetic_event = { - "type": "message.add", - "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(), - "payload": { - "messageId": str(payload.get("_id") or payload.get("messageId") or ""), - "author": str(payload.get("author") or ""), - "authorInfo": payload.get("authorInfo") if isinstance(payload.get("authorInfo"), dict) else {}, - "content": payload.get("content"), - "meta": payload.get("meta") if isinstance(payload.get("meta"), dict) else {}, - "groupId": group_id, - "converseId": panel_id, - }, - } - await self._process_inbound_event( - target_id=panel_id, - event=synthetic_event, - target_kind="panel", + evt = _make_synthetic_event( + message_id=str(payload.get("_id") or payload.get("messageId") or ""), + author=str(payload.get("author") or ""), + content=payload.get("content"), meta=payload.get("meta"), + group_id=group_id, converse_id=panel_id, + timestamp=payload.get("createdAt"), author_info=payload.get("authorInfo"), ) + await self._process_inbound_event(panel_id, evt, "panel") async def _handle_notify_inbox_append(self, payload: Any) -> None: - if not isinstance(payload, dict): + if not isinstance(payload, dict) or payload.get("type") != "message": return - - if payload.get("type") != "message": - return - detail = payload.get("payload") if not isinstance(detail, dict): return - - group_id = str(detail.get("groupId") or "").strip() - if group_id: + if _str_field(detail, "groupId"): return - - converse_id = str(detail.get("converseId") or "").strip() + converse_id = _str_field(detail, "converseId") if not converse_id: return session_id = self._session_by_converse.get(converse_id) if not session_id: - await self._refresh_sessions_directory(subscribe_new=self._ws_ready) + await self._refresh_sessions_directory(self._ws_ready) session_id = self._session_by_converse.get(converse_id) if not session_id: return - message_id = str(detail.get("messageId") or payload.get("_id") or "").strip() - author = str(detail.get("messageAuthor") or "").strip() - content = str(detail.get("messagePlainContent") or detail.get("messageSnippet") or "").strip() - - synthetic_event = { - "type": "message.add", - "timestamp": payload.get("createdAt") or datetime.utcnow().isoformat(), - "payload": { - "messageId": message_id, - "author": author, - "content": content, - "meta": { - "source": "notify:chat.inbox.append", - "converseId": converse_id, - }, - "converseId": converse_id, - }, - } - - await self._process_inbound_event( - target_id=session_id, - event=synthetic_event, - target_kind="session", + evt = _make_synthetic_event( + message_id=str(detail.get("messageId") or payload.get("_id") or ""), + author=str(detail.get("messageAuthor") or ""), + content=str(detail.get("messagePlainContent") or detail.get("messageSnippet") or ""), + meta={"source": "notify:chat.inbox.append", "converseId": converse_id}, + group_id="", converse_id=converse_id, timestamp=payload.get("createdAt"), ) + await self._process_inbound_event(session_id, evt, "session") + + # ---- cursor persistence ------------------------------------------------ def _mark_session_cursor(self, session_id: str, cursor: int) -> None: - if cursor < 0: + if cursor < 0 or cursor < self._session_cursor.get(session_id, 0): return - - previous = self._session_cursor.get(session_id, 0) - if cursor < previous: - return - self._session_cursor[session_id] = cursor - self._schedule_cursor_save() - - def _schedule_cursor_save(self) -> None: - if self._cursor_save_task and not self._cursor_save_task.done(): - return - - self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced()) + if not self._cursor_save_task or self._cursor_save_task.done(): + self._cursor_save_task = asyncio.create_task(self._save_cursor_debounced()) async def _save_cursor_debounced(self) -> None: await asyncio.sleep(CURSOR_SAVE_DEBOUNCE_S) @@ -1088,140 +833,63 @@ class MochatChannel(BaseChannel): async def _load_session_cursors(self) -> None: if not self._cursor_path.exists(): return - try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: logger.warning(f"Failed to read Mochat cursor file: {e}") return - cursors = data.get("cursors") if isinstance(data, dict) else None - if not isinstance(cursors, dict): - return - - for session_id, cursor in cursors.items(): - if isinstance(session_id, str) and isinstance(cursor, int) and cursor >= 0: - self._session_cursor[session_id] = cursor + if isinstance(cursors, dict): + for sid, cur in cursors.items(): + if isinstance(sid, str) and isinstance(cur, int) and cur >= 0: + self._session_cursor[sid] = cur async def _save_session_cursors(self) -> None: - payload = { - "schemaVersion": 1, - "updatedAt": datetime.utcnow().isoformat(), - "cursors": self._session_cursor, - } - try: 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") + self._cursor_path.write_text(json.dumps({ + "schemaVersion": 1, "updatedAt": datetime.utcnow().isoformat(), + "cursors": self._session_cursor, + }, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: logger.warning(f"Failed to save Mochat cursor file: {e}") - def _base_url(self) -> str: - return self.config.base_url.strip().rstrip("/") + # ---- HTTP helpers ------------------------------------------------------ async def _post_json(self, path: str, payload: dict[str, Any]) -> dict[str, Any]: if not self._http: raise RuntimeError("Mochat HTTP client not initialized") - - url = f"{self._base_url()}{path}" - response = await self._http.post( - url, - headers={ - "Content-Type": "application/json", - "X-Claw-Token": self.config.claw_token, - }, - json=payload, - ) - - text = response.text + url = f"{self.config.base_url.strip().rstrip('/')}{path}" + response = await self._http.post(url, headers={ + "Content-Type": "application/json", "X-Claw-Token": self.config.claw_token, + }, json=payload) if not response.is_success: - raise RuntimeError(f"Mochat HTTP {response.status_code}: {text[:200]}") - - parsed: Any + raise RuntimeError(f"Mochat HTTP {response.status_code}: {response.text[:200]}") try: parsed = response.json() except Exception: - parsed = text - + parsed = response.text 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"Mochat API error: {message} (code={parsed['code']})") + msg = str(parsed.get("message") or parsed.get("name") or "request failed") + raise RuntimeError(f"Mochat API error: {msg} (code={parsed['code']})") data = parsed.get("data") return data if isinstance(data, dict) else {} + return parsed if isinstance(parsed, dict) else {} - if isinstance(parsed, dict): - return parsed - - return {} - - async def _watch_session( - self, - session_id: str, - cursor: int, - timeout_ms: int, - limit: int, - ) -> dict[str, Any]: - return await self._post_json( - "/api/claw/sessions/watch", - { - "sessionId": session_id, - "cursor": cursor, - "timeoutMs": timeout_ms, - "limit": limit, - }, - ) - - async def _send_session_message( - self, - session_id: str, - content: str, - reply_to: str | None, - ) -> dict[str, Any]: - payload = { - "sessionId": session_id, - "content": content, - } + async def _api_send(self, path: str, id_key: str, id_val: str, + content: str, reply_to: str | None, group_id: str | None = None) -> dict[str, Any]: + """Unified send helper for session and panel messages.""" + body: dict[str, Any] = {id_key: id_val, "content": content} if reply_to: - payload["replyTo"] = reply_to - return await self._post_json("/api/claw/sessions/send", payload) - - async def _send_panel_message( - self, - panel_id: str, - content: str, - reply_to: str | None, - group_id: str | None, - ) -> dict[str, Any]: - payload = { - "panelId": panel_id, - "content": content, - } - if reply_to: - payload["replyTo"] = reply_to + body["replyTo"] = reply_to if group_id: - payload["groupId"] = group_id - return await self._post_json("/api/claw/groups/panels/send", payload) + body["groupId"] = group_id + return await self._post_json(path, body) - async def _list_sessions(self) -> dict[str, Any]: - return await self._post_json("/api/claw/sessions/list", {}) - - async def _get_workspace_group(self) -> dict[str, Any]: - return await self._post_json("/api/claw/groups/get", {}) - - async def _list_panel_messages(self, panel_id: str, limit: int) -> dict[str, Any]: - return await self._post_json( - "/api/claw/groups/panels/messages", - { - "panelId": panel_id, - "limit": limit, - }, - ) - - def _read_group_id(self, metadata: dict[str, Any]) -> str | None: + @staticmethod + def _read_group_id(metadata: dict[str, Any]) -> str | None: if not isinstance(metadata, dict): return None value = metadata.get("group_id") or metadata.get("groupId") - if isinstance(value, str) and value.strip(): - return value.strip() - return None + return value.strip() if isinstance(value, str) and value.strip() else None From cd4eeb1d204c3355684e192a7e055a2716249300 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 07:22:03 +0000 Subject: [PATCH 50/58] docs: update mochat guidelines --- README.md | 51 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 0c74e17..b749b31 100644 --- a/README.md +++ b/README.md @@ -221,40 +221,55 @@ nanobot gateway Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. -**1. Prepare credentials** -- `clawToken`: Claw API token -- `agentUserId`: your bot user id -- Optional: `sessions`/`panels` with `["*"]` for auto-discovery +**1. Ask nanobot to set up Mochat for you** -**2. Configure** +Simply send this message to nanobot (replace `xxx@xxx` with your real email): + +``` +Read https://raw.githubusercontent.com/HKUDS/MoChat/refs/heads/main/skills/nanobot/skill.md and register on MoChat. My Email account is xxx@xxx Bind me as your owner and DM me on MoChat. +``` + +nanobot will automatically register, configure `~/.nanobot/config.json`, and connect to Mochat. + +**2. Restart gateway** + +```bash +nanobot gateway +``` + +That's it โ€” nanobot handles the rest! + +
+ +
+Manual configuration (advanced) + +If you prefer to configure manually, add the following to `~/.nanobot/config.json`: + +> Keep `claw_token` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint. ```json { "channels": { "mochat": { "enabled": true, - "baseUrl": "https://mochat.io", - "socketUrl": "https://mochat.io", - "socketPath": "/socket.io", - "clawToken": "claw_xxx", - "agentUserId": "6982abcdef", + "base_url": "https://mochat.io", + "socket_url": "https://mochat.io", + "socket_path": "/socket.io", + "claw_token": "claw_xxx", + "agent_user_id": "6982abcdef", "sessions": ["*"], "panels": ["*"], - "replyDelayMode": "non-mention", - "replyDelayMs": 120000 + "reply_delay_mode": "non-mention", + "reply_delay_ms": 120000 } } } ``` -**3. Run** -```bash -nanobot gateway -``` -> [!TIP] -> Keep `clawToken` private. It should only be sent in `X-Claw-Token` header to your Mochat API endpoint. +
From 8626caff742c6e68f6fc4a951bc2ec8d9ab2424b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 07:39:15 +0000 Subject: [PATCH 51/58] fix: prevent safety guard from blocking relative paths in exec tool --- nanobot/agent/tools/shell.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 143d187..18eff64 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -128,14 +128,17 @@ class ExecTool(Tool): cwd_path = Path(cwd).resolve() win_paths = re.findall(r"[A-Za-z]:\\[^\\\"']+", cmd) - posix_paths = re.findall(r"/[^\s\"']+", cmd) + # Only match absolute paths โ€” avoid false positives on relative + # paths like ".venv/bin/python" where "/bin/python" would be + # incorrectly extracted by the old pattern. + posix_paths = re.findall(r"(?:^|[\s|>])(/[^\s\"'>]+)", cmd) for raw in win_paths + posix_paths: try: - p = Path(raw).resolve() + p = Path(raw.strip()).resolve() except Exception: continue - if cwd_path not in p.parents and p != cwd_path: + if p.is_absolute() and cwd_path not in p.parents and p != cwd_path: return "Error: Command blocked by safety guard (path outside working dir)" return None From ef1b062be5dd3f4af5cc3888d84ac73d93c98a3e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 07:42:39 +0000 Subject: [PATCH 52/58] fix: create skills dir on onboard --- nanobot/cli/commands.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index bcadba9..a200e67 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -294,6 +294,10 @@ This file stores important information that should persist across sessions. """) console.print(" [dim]Created memory/MEMORY.md[/dim]") + # Create skills directory for custom user skills + skills_dir = workspace / "skills" + skills_dir.mkdir(exist_ok=True) + def _make_provider(config): """Create LiteLLMProvider from config. Exits if no API key found.""" From c98ca70d3041c2b802fcffa0d29eb5dd4245ab3b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Feb 2026 08:38:36 +0000 Subject: [PATCH 53/58] docs: update provider tips --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b749b31..744361a 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ pip install nanobot-ai > [!TIP] > Set your API key in `~/.nanobot/config.json`. -> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global) ยท [DashScope](https://dashscope.console.aliyun.com) (Qwen) ยท [Brave Search](https://brave.com/search/api/) (optional, for web search) +> Get API keys: [OpenRouter](https://openrouter.ai/keys) (Global) ยท [Brave Search](https://brave.com/search/api/) (optional, for web search) **1. Initialize** From 08b9270e0accb85fc380bda71d9cbed345ec5776 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Tue, 10 Feb 2026 19:50:09 +0800 Subject: [PATCH 54/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 744361a..964c81e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check out the latest updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. From 9ee65cd681fc35fed94b6706edda02a183e6a7b9 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Tue, 10 Feb 2026 19:50:47 +0800 Subject: [PATCH 55/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 964c81e..6789246 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check out the latest updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check out the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. From ca7d6bf1abef9a203f4e3c67a9fd6cc5c02ec815 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Tue, 10 Feb 2026 19:51:12 +0800 Subject: [PATCH 56/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6789246..3e70a71 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check out the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. From eca16947be98547ac180b2cd56b782464997a1f7 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Tue, 10 Feb 2026 19:51:46 +0800 Subject: [PATCH 57/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3e70a71..01643b1 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with multiple improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). +- **2026-02-10** ๐ŸŽ‰ Released v0.1.3.post6 with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431). - **2026-02-09** ๐Ÿ’ฌ Added Slack, Email, and QQ support โ€” nanobot now supports multiple chat platforms! - **2026-02-08** ๐Ÿ”ง Refactored Providersโ€”adding a new LLM provider now takes just 2 simple steps! Check [here](#providers). - **2026-02-07** ๐Ÿš€ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details. From f8de53c7c1d62ba42944f8dc905f829d2b6a8d04 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Tue, 10 Feb 2026 20:46:13 +0800 Subject: [PATCH 58/58] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 01643b1..4a26a2f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@

-๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [Clawdbot](https://github.com/openclaw/openclaw) +๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw) โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines.