From d7b72c8f83b105674852728bea9fb534166579c0 Mon Sep 17 00:00:00 2001 From: cwu Date: Fri, 6 Feb 2026 12:24:11 -0500 Subject: [PATCH 01/20] 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 02/20] 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 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 03/20] 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 04/20] 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 05/20] 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 f49c639b74ced46df483ad12523580cd5e51da81 Mon Sep 17 00:00:00 2001 From: chaohuang-ai Date: Sun, 8 Feb 2026 18:02:48 +0800 Subject: [PATCH 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] =?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 11/20] 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 12/20] 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 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 20/20] 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")