From 5033ac175987d08f8e28f39c1fe346a593d72d73 Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Mon, 16 Feb 2026 15:02:12 +0100 Subject: [PATCH 01/13] Added Github Copilot Provider --- nanobot/cli/commands.py | 2 +- nanobot/config/schema.py | 1 + nanobot/providers/registry.py | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 235bfdc..affd421 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -292,7 +292,7 @@ def _make_provider(config: Config): if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider(default_model=model) - if not model.startswith("bedrock/") and not (p and p.api_key): + if not model.startswith("bedrock/") and not (p and p.api_key) and provider_name != "github_copilot": console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 15b6bb2..64609ec 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -193,6 +193,7 @@ class ProvidersConfig(BaseModel): minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) + github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) class GatewayConfig(BaseModel): diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 59af5e1..1e760d6 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -177,6 +177,25 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_oauth=True, # OAuth-based authentication ), + # Github Copilot: uses OAuth, not API key. + ProviderSpec( + name="github_copilot", + keywords=("github_copilot", "copilot"), + env_key="", # OAuth-based, no API key + display_name="Github Copilot", + litellm_prefix="github_copilot", # github_copilot/model β†’ github_copilot/model + skip_prefixes=("github_copilot/",), + env_extras=(), + is_gateway=False, + is_local=False, + detect_by_key_prefix="", + detect_by_base_keyword="", + default_api_base="", + strip_model_prefix=False, + model_overrides=(), + is_oauth=True, # OAuth-based authentication + ), + # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", From 23b7e1ef5e944a05653342a70efa2e1fbba9109f Mon Sep 17 00:00:00 2001 From: Darye <54469750+DaryeDev@users.noreply.github.com> Date: Mon, 16 Feb 2026 16:29:03 +0100 Subject: [PATCH 02/13] Handle media files (voice messages, audio, images, documents) on Telegram Channel --- nanobot/agent/tools/message.py | 12 +++++-- nanobot/channels/telegram.py | 64 +++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 11 deletions(-) diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 347830f..3853725 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -52,6 +52,11 @@ class MessageTool(Tool): "chat_id": { "type": "string", "description": "Optional: target chat/user ID" + }, + "media": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: list of file paths to attach (images, audio, documents)" } }, "required": ["content"] @@ -62,6 +67,7 @@ class MessageTool(Tool): content: str, channel: str | None = None, chat_id: str | None = None, + media: list[str] | None = None, **kwargs: Any ) -> str: channel = channel or self._default_channel @@ -76,11 +82,13 @@ class MessageTool(Tool): msg = OutboundMessage( channel=channel, chat_id=chat_id, - content=content + content=content, + media=media or [] ) try: await self._send_callback(msg) - return f"Message sent to {channel}:{chat_id}" + media_info = f" with {len(media)} attachments" if media else "" + return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: return f"Error sending message: {str(e)}" diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c9978c2..8f135e4 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -198,6 +198,18 @@ class TelegramChannel(BaseChannel): await self._app.shutdown() self._app = None + def _get_media_type(self, path: str) -> str: + """Guess media type from file extension.""" + path = path.lower() + if path.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): + return "photo" + elif path.endswith('.ogg'): + return "voice" + elif path.endswith(('.mp3', '.m4a', '.wav', '.aac')): + return "audio" + else: + return "document" + async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" if not self._app: @@ -212,16 +224,50 @@ class TelegramChannel(BaseChannel): logger.error(f"Invalid chat_id: {msg.chat_id}") return - for chunk in _split_message(msg.content): - try: - html = _markdown_to_telegram_html(chunk) - await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") - except Exception as e: - logger.warning(f"HTML parse failed, falling back to plain text: {e}") + # Handle media files + if msg.media: + for media_path in msg.media: try: - await self._app.bot.send_message(chat_id=chat_id, text=chunk) - except Exception as e2: - logger.error(f"Error sending Telegram message: {e2}") + media_type = self._get_media_type(media_path) + + # Determine caption (only for first media or if explicitly set, + # but here we keep it simple: content is sent separately if media is present + # to avoid length issues, unless we want to attach it to the first media) + # For simplicity: send media first, then text if present. + # Or: if single media, attach text as caption. + + # Let's attach content as caption to the last media if single, + # otherwise send text separately. + + with open(media_path, 'rb') as f: + if media_type == "photo": + await self._app.bot.send_photo(chat_id=chat_id, photo=f) + elif media_type == "voice": + await self._app.bot.send_voice(chat_id=chat_id, voice=f) + elif media_type == "audio": + await self._app.bot.send_audio(chat_id=chat_id, audio=f) + else: + await self._app.bot.send_document(chat_id=chat_id, document=f) + + except Exception as e: + logger.error(f"Failed to send media {media_path}: {e}") + await self._app.bot.send_message( + chat_id=chat_id, + text=f"[Failed to send file: {media_path}]" + ) + + # Send text content if present + if msg.content and msg.content != "[empty message]": + for chunk in _split_message(msg.content): + try: + html = _markdown_to_telegram_html(chunk) + await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") + except Exception as e: + logger.warning(f"HTML parse failed, falling back to plain text: {e}") + try: + await self._app.bot.send_message(chat_id=chat_id, text=chunk) + except Exception as e2: + logger.error(f"Error sending Telegram message: {e2}") async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" From ae903e983c2c0a7aac3f57fc9a66f7e13dbf4456 Mon Sep 17 00:00:00 2001 From: jopo Date: Mon, 16 Feb 2026 17:49:19 -0800 Subject: [PATCH 03/13] fix(cron): improve timezone scheduling and tz propagation --- nanobot/agent/tools/cron.py | 20 +++++++++++++++++--- nanobot/cli/commands.py | 22 +++++++++++++++++++--- nanobot/cron/service.py | 3 ++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9f1ecdb..9dc31c2 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -50,6 +50,10 @@ class CronTool(Tool): "type": "string", "description": "Cron expression like '0 9 * * *' (for scheduled tasks)" }, + "tz": { + "type": "string", + "description": "IANA timezone for cron expressions (e.g. 'America/Vancouver')" + }, "at": { "type": "string", "description": "ISO datetime for one-time execution (e.g. '2026-02-12T10:30:00')" @@ -68,30 +72,40 @@ class CronTool(Tool): message: str = "", every_seconds: int | None = None, cron_expr: str | None = None, + tz: str | None = None, at: str | None = None, job_id: str | None = None, **kwargs: Any ) -> str: if action == "add": - return self._add_job(message, every_seconds, cron_expr, at) + return self._add_job(message, every_seconds, cron_expr, tz, at) elif action == "list": return self._list_jobs() elif action == "remove": return self._remove_job(job_id) return f"Unknown action: {action}" - def _add_job(self, message: str, every_seconds: int | None, cron_expr: str | None, at: str | None) -> str: + def _add_job( + self, + message: str, + every_seconds: int | None, + cron_expr: str | None, + tz: str | None, + at: str | None, + ) -> str: if not message: return "Error: message is required for add" if not self._channel or not self._chat_id: return "Error: no session context (channel/chat_id)" + if tz and not cron_expr: + return "Error: tz can only be used with cron_expr" # Build schedule delete_after = False if every_seconds: schedule = CronSchedule(kind="every", every_ms=every_seconds * 1000) elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr) + schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: from datetime import datetime dt = datetime.fromisoformat(at) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 235bfdc..3b58db5 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -719,21 +719,32 @@ def cron_list( table.add_column("Status") table.add_column("Next Run") + import datetime import time + from zoneinfo import ZoneInfo for job in jobs: # Format schedule if job.schedule.kind == "every": sched = f"every {(job.schedule.every_ms or 0) // 1000}s" elif job.schedule.kind == "cron": sched = job.schedule.expr or "" + if job.schedule.tz: + sched = f"{sched} ({job.schedule.tz})" 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_run = next_time + ts = job.state.next_run_at_ms / 1000 + if job.schedule.kind == "cron" and job.schedule.tz: + try: + dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz)) + next_run = dt.strftime("%Y-%m-%d %H:%M") + except Exception: + next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) + else: + next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" @@ -748,6 +759,7 @@ def cron_add( message: str = typer.Option(..., "--message", "-m", help="Message for agent"), every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"), cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"), + tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"), 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"), @@ -758,11 +770,15 @@ def cron_add( from nanobot.cron.service import CronService from nanobot.cron.types import CronSchedule + if tz and not cron_expr: + console.print("[red]Error: --tz can only be used with --cron[/red]") + raise typer.Exit(1) + # Determine schedule type if every: schedule = CronSchedule(kind="every", every_ms=every * 1000) elif cron_expr: - schedule = CronSchedule(kind="cron", expr=cron_expr) + schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz) elif at: import datetime dt = datetime.datetime.fromisoformat(at) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 4da845a..9fda214 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -32,7 +32,8 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: try: from croniter import croniter from zoneinfo import ZoneInfo - base_time = time.time() + # Use the caller-provided reference time for deterministic scheduling. + base_time = now_ms / 1000 tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo base_dt = datetime.fromtimestamp(base_time, tz=tz) cron = croniter(schedule.expr, base_dt) From 56bc8b567733480091d0f4eb4636d8bd31ded1ac Mon Sep 17 00:00:00 2001 From: nano bot Date: Tue, 17 Feb 2026 03:52:08 +0000 Subject: [PATCH 04/13] fix: avoid sending empty content entries in assistant messages --- nanobot/agent/context.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index f460f2b..bed9e36 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -225,14 +225,20 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" Returns: Updated message list. """ - msg: dict[str, Any] = {"role": "assistant", "content": content or ""} - + msg: dict[str, Any] = {"role": "assistant"} + + # Only include the content key when there is non-empty text. + # Some LLM backends reject empty text blocks, so omit the key + # to avoid sending empty content entries. + if content is not None and content != "": + msg["content"] = content + if tool_calls: msg["tool_calls"] = tool_calls - - # Thinking models reject history without this + + # Include reasoning content when provided (required by some thinking models) if reasoning_content: msg["reasoning_content"] = reasoning_content - + messages.append(msg) return messages From 5735f9bdcee8f7a415cad6b206dd315c1f94f5f1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:14:16 +0000 Subject: [PATCH 05/13] feat: add ClawHub skill for searching and installing agent skills from the public registry --- nanobot/skills/README.md | 1 + nanobot/skills/clawhub/SKILL.md | 53 +++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 nanobot/skills/clawhub/SKILL.md diff --git a/nanobot/skills/README.md b/nanobot/skills/README.md index f0dcea7..5192796 100644 --- a/nanobot/skills/README.md +++ b/nanobot/skills/README.md @@ -21,4 +21,5 @@ The skill format and metadata structure follow OpenClaw's conventions to maintai | `weather` | Get weather info using wttr.in and Open-Meteo | | `summarize` | Summarize URLs, files, and YouTube videos | | `tmux` | Remote-control tmux sessions | +| `clawhub` | Search and install skills from ClawHub registry | | `skill-creator` | Create new skills | \ No newline at end of file diff --git a/nanobot/skills/clawhub/SKILL.md b/nanobot/skills/clawhub/SKILL.md new file mode 100644 index 0000000..7409bf4 --- /dev/null +++ b/nanobot/skills/clawhub/SKILL.md @@ -0,0 +1,53 @@ +--- +name: clawhub +description: Search and install agent skills from ClawHub, the public skill registry. +homepage: https://clawhub.ai +metadata: {"nanobot":{"emoji":"🦞"}} +--- + +# ClawHub + +Public skill registry for AI agents. Search by natural language (vector search). + +## When to use + +Use this skill when the user asks any of: +- "find a skill for …" +- "search for skills" +- "install a skill" +- "what skills are available?" +- "update my skills" + +## Search + +```bash +npx --yes clawhub@latest search "web scraping" --limit 5 +``` + +## Install + +```bash +npx --yes clawhub@latest install --workdir ~/.nanobot/workspace +``` + +Replace `` with the skill name from search results. This places the skill into `~/.nanobot/workspace/skills/`, where nanobot loads workspace skills from. Always include `--workdir`. + +## Update + +```bash +npx --yes clawhub@latest update --all --workdir ~/.nanobot/workspace +``` + +## List installed + +```bash +npx --yes clawhub@latest list --workdir ~/.nanobot/workspace +``` + +## Notes + +- Requires Node.js (`npx` comes with it). +- No API key needed for search and install. +- Login (`npx --yes clawhub@latest login`) is only required for publishing. +- `--workdir ~/.nanobot/workspace` is critical β€” without it, skills install to the current directory instead of the nanobot workspace. +- After install, remind the user to start a new session to load the skill. From 8509a81120201dd4783277f202698984fe9ee151 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:19:23 +0000 Subject: [PATCH 06/13] docs: update 15/16 Feb news --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0584dd8..a27bbc8 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ ## πŸ“’ News +- **2026-02-16** 🦞 nanobot now integrates with [ClawHub](https://clawhub.ai) β€” search and install agent skills from the public registry. +- **2026-02-15** πŸ”‘ nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** πŸ”Œ nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. - **2026-02-13** πŸŽ‰ Released v0.1.3.post7 β€” includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. - **2026-02-12** 🧠 Redesigned memory system β€” Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it! From cf4dce5df05008b2da0b88445a1b4bd76e1aaf5a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:20:50 +0000 Subject: [PATCH 07/13] docs: update clawhub news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a27bbc8..38afa82 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## πŸ“’ News -- **2026-02-16** 🦞 nanobot now integrates with [ClawHub](https://clawhub.ai) β€” search and install agent skills from the public registry. +- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill β€” search and install public agent skills. - **2026-02-15** πŸ”‘ nanobot now supports OpenAI Codex provider with OAuth login support. - **2026-02-14** πŸ”Œ nanobot now supports MCP! See [MCP section](#mcp-model-context-protocol) for details. - **2026-02-13** πŸŽ‰ Released v0.1.3.post7 β€” includes security hardening and multiple improvements. All users are recommended to upgrade to the latest version. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details. From 6bae6a617f7130e0a1021811b8cd8b379c2c0820 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:30:52 +0000 Subject: [PATCH 08/13] fix(cron): fix timezone display bug, add tz validation and skill docs --- README.md | 2 +- nanobot/agent/tools/cron.py | 6 ++++++ nanobot/cli/commands.py | 17 ++++++----------- nanobot/cron/service.py | 2 +- nanobot/skills/cron/SKILL.md | 10 ++++++++++ 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 38afa82..de517d7 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,668 lines** (run `bash core_agent_lines.sh` to verify anytime) +πŸ“ Real-time line count: **3,689 lines** (run `bash core_agent_lines.sh` to verify anytime) ## πŸ“’ News diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 9dc31c2..b10e34b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -99,6 +99,12 @@ class CronTool(Tool): return "Error: no session context (channel/chat_id)" if tz and not cron_expr: return "Error: tz can only be used with cron_expr" + if tz: + from zoneinfo import ZoneInfo + try: + ZoneInfo(tz) + except (KeyError, Exception): + return f"Error: unknown timezone '{tz}'" # Build schedule delete_after = False diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3b58db5..3798813 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -719,17 +719,15 @@ def cron_list( table.add_column("Status") table.add_column("Next Run") - import datetime import time + from datetime import datetime as _dt from zoneinfo import ZoneInfo for job in jobs: # Format schedule if job.schedule.kind == "every": sched = f"every {(job.schedule.every_ms or 0) // 1000}s" elif job.schedule.kind == "cron": - sched = job.schedule.expr or "" - if job.schedule.tz: - sched = f"{sched} ({job.schedule.tz})" + sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "") else: sched = "one-time" @@ -737,13 +735,10 @@ def cron_list( next_run = "" if job.state.next_run_at_ms: ts = job.state.next_run_at_ms / 1000 - if job.schedule.kind == "cron" and job.schedule.tz: - try: - dt = datetime.fromtimestamp(ts, ZoneInfo(job.schedule.tz)) - next_run = dt.strftime("%Y-%m-%d %H:%M") - except Exception: - next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) - else: + try: + tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None + next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M") + except Exception: next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts)) status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]" diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 9fda214..14666e8 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -32,7 +32,7 @@ def _compute_next_run(schedule: CronSchedule, now_ms: int) -> int | None: try: from croniter import croniter from zoneinfo import ZoneInfo - # Use the caller-provided reference time for deterministic scheduling. + # Use caller-provided reference time for deterministic scheduling base_time = now_ms / 1000 tz = ZoneInfo(schedule.tz) if schedule.tz else datetime.now().astimezone().tzinfo base_dt = datetime.fromtimestamp(base_time, tz=tz) diff --git a/nanobot/skills/cron/SKILL.md b/nanobot/skills/cron/SKILL.md index 7db25d8..cc3516e 100644 --- a/nanobot/skills/cron/SKILL.md +++ b/nanobot/skills/cron/SKILL.md @@ -30,6 +30,11 @@ One-time scheduled task (compute ISO datetime from current time): cron(action="add", message="Remind me about the meeting", at="") ``` +Timezone-aware cron: +``` +cron(action="add", message="Morning standup", cron_expr="0 9 * * 1-5", tz="America/Vancouver") +``` + List/remove: ``` cron(action="list") @@ -44,4 +49,9 @@ cron(action="remove", job_id="abc123") | every hour | every_seconds: 3600 | | every day at 8am | cron_expr: "0 8 * * *" | | weekdays at 5pm | cron_expr: "0 17 * * 1-5" | +| 9am Vancouver time daily | cron_expr: "0 9 * * *", tz: "America/Vancouver" | | at a specific time | at: ISO datetime string (compute from current time) | + +## Timezone + +Use `tz` with `cron_expr` to schedule in a specific IANA timezone. Without `tz`, the server's local timezone is used. From f5c5b13ff03316b925bd37b58adce144d4153c92 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:41:09 +0000 Subject: [PATCH 09/13] refactor: use is_oauth flag instead of hardcoded provider name check --- README.md | 25 +++++++++++++------------ nanobot/cli/commands.py | 4 +++- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index de517d7..0e20449 100644 --- a/README.md +++ b/README.md @@ -145,19 +145,19 @@ That's it! You have a working AI assistant in 2 minutes. ## πŸ’¬ Chat Apps -Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ β€” anytime, anywhere. +Connect nanobot to your favorite chat platform. -| Channel | Setup | -|---------|-------| -| **Telegram** | Easy (just a token) | -| **Discord** | Easy (bot token + intents) | -| **WhatsApp** | Medium (scan QR) | -| **Feishu** | Medium (app credentials) | -| **Mochat** | Medium (claw token + websocket) | -| **DingTalk** | Medium (app credentials) | -| **Slack** | Medium (bot + app tokens) | -| **Email** | Medium (IMAP/SMTP credentials) | -| **QQ** | Easy (app credentials) | +| Channel | What you need | +|---------|---------------| +| **Telegram** | Bot token from @BotFather | +| **Discord** | Bot token + Message Content intent | +| **WhatsApp** | QR code scan | +| **Feishu** | App ID + App Secret | +| **Mochat** | Claw token (auto-setup available) | +| **DingTalk** | App Key + App Secret | +| **Slack** | Bot token + App-Level token | +| **Email** | IMAP/SMTP credentials | +| **QQ** | App ID + App Secret |
Telegram (Recommended) @@ -588,6 +588,7 @@ Config file: `~/.nanobot/config.json` | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `vllm` | LLM (local, any OpenAI-compatible server) | β€” | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | +| `github_copilot` | LLM (GitHub Copilot, OAuth) | Requires [GitHub Copilot](https://github.com/features/copilot) subscription |
OpenAI Codex (OAuth) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index a24929f..b61d9aa 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -292,7 +292,9 @@ def _make_provider(config: Config): if provider_name == "openai_codex" or model.startswith("openai-codex/"): return OpenAICodexProvider(default_model=model) - if not model.startswith("bedrock/") and not (p and p.api_key) and provider_name != "github_copilot": + from nanobot.providers.registry import find_by_name + spec = find_by_name(provider_name) + if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and spec.is_oauth): console.print("[red]Error: No API key configured.[/red]") console.print("Set one in ~/.nanobot/config.json under providers section") raise typer.Exit(1) From 1db05c881ddf7b3958edda7f99ff02cd49581f5f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 08:59:05 +0000 Subject: [PATCH 10/13] fix: omit empty content in assistant messages --- nanobot/agent/context.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index bed9e36..cfd6318 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -227,10 +227,8 @@ To recall past events, grep {workspace_path}/memory/HISTORY.md""" """ msg: dict[str, Any] = {"role": "assistant"} - # Only include the content key when there is non-empty text. - # Some LLM backends reject empty text blocks, so omit the key - # to avoid sending empty content entries. - if content is not None and content != "": + # Omit empty content β€” some backends reject empty text blocks + if content: msg["content"] = content if tool_calls: From 5ad9c837df8879717a01760530b830dd3c67cd7b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 10:37:55 +0000 Subject: [PATCH 11/13] refactor: clean up telegram media sending logic --- nanobot/channels/telegram.py | 63 ++++++++++++++---------------------- 1 file changed, 24 insertions(+), 39 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 8f135e4..39924b3 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -198,17 +198,17 @@ class TelegramChannel(BaseChannel): await self._app.shutdown() self._app = None - def _get_media_type(self, path: str) -> str: + @staticmethod + def _get_media_type(path: str) -> str: """Guess media type from file extension.""" - path = path.lower() - if path.endswith(('.jpg', '.jpeg', '.png', '.gif', '.webp')): + ext = path.rsplit(".", 1)[-1].lower() if "." in path else "" + if ext in ("jpg", "jpeg", "png", "gif", "webp"): return "photo" - elif path.endswith('.ogg'): + if ext == "ogg": return "voice" - elif path.endswith(('.mp3', '.m4a', '.wav', '.aac')): + if ext in ("mp3", "m4a", "wav", "aac"): return "audio" - else: - return "document" + return "document" async def send(self, msg: OutboundMessage) -> None: """Send a message through Telegram.""" @@ -224,39 +224,24 @@ class TelegramChannel(BaseChannel): logger.error(f"Invalid chat_id: {msg.chat_id}") return - # Handle media files - if msg.media: - for media_path in msg.media: - try: - media_type = self._get_media_type(media_path) - - # Determine caption (only for first media or if explicitly set, - # but here we keep it simple: content is sent separately if media is present - # to avoid length issues, unless we want to attach it to the first media) - # For simplicity: send media first, then text if present. - # Or: if single media, attach text as caption. - - # Let's attach content as caption to the last media if single, - # otherwise send text separately. - - with open(media_path, 'rb') as f: - if media_type == "photo": - await self._app.bot.send_photo(chat_id=chat_id, photo=f) - elif media_type == "voice": - await self._app.bot.send_voice(chat_id=chat_id, voice=f) - elif media_type == "audio": - await self._app.bot.send_audio(chat_id=chat_id, audio=f) - else: - await self._app.bot.send_document(chat_id=chat_id, document=f) - - except Exception as e: - logger.error(f"Failed to send media {media_path}: {e}") - await self._app.bot.send_message( - chat_id=chat_id, - text=f"[Failed to send file: {media_path}]" - ) + # Send media files + for media_path in (msg.media or []): + try: + media_type = self._get_media_type(media_path) + sender = { + "photo": self._app.bot.send_photo, + "voice": self._app.bot.send_voice, + "audio": self._app.bot.send_audio, + }.get(media_type, self._app.bot.send_document) + param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" + with open(media_path, 'rb') as f: + await sender(chat_id=chat_id, **{param: f}) + except Exception as e: + filename = media_path.rsplit("/", 1)[-1] + logger.error(f"Failed to send media {media_path}: {e}") + await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]") - # Send text content if present + # Send text content if msg.content and msg.content != "[empty message]": for chunk in _split_message(msg.content): try: From 4d4d6299283f51c31357984e3a34d75518fd0501 Mon Sep 17 00:00:00 2001 From: Simon Guigui Date: Tue, 17 Feb 2026 15:19:21 +0100 Subject: [PATCH 12/13] fix(config): mcpServers env variables should not be converted to snake case --- nanobot/config/loader.py | 55 ++++---------------- nanobot/config/schema.py | 109 ++++++++++++++++++++++++++------------- 2 files changed, 83 insertions(+), 81 deletions(-) diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index fd7d1e8..560c1f5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -2,7 +2,6 @@ import json from pathlib import Path -from typing import Any from nanobot.config.schema import Config @@ -21,43 +20,41 @@ def get_data_dir() -> Path: def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. - + Args: config_path: Optional path to config file. Uses default if not provided. - + Returns: Loaded configuration object. """ path = config_path or get_config_path() - + if path.exists(): try: with open(path) as f: data = json.load(f) data = _migrate_config(data) - return Config.model_validate(convert_keys(data)) + return Config.model_validate(data) except (json.JSONDecodeError, ValueError) as e: print(f"Warning: Failed to load config from {path}: {e}") print("Using default configuration.") - + return Config() def save_config(config: Config, config_path: Path | None = None) -> None: """ Save configuration to file. - + Args: config: Configuration to save. config_path: Optional path to save to. Uses default if not provided. """ path = config_path or get_config_path() path.parent.mkdir(parents=True, exist_ok=True) - - # Convert to camelCase format - data = config.model_dump() - data = convert_to_camel(data) - + + data = config.model_dump(by_alias=True) + with open(path, "w") as f: json.dump(data, f, indent=2) @@ -70,37 +67,3 @@ def _migrate_config(data: dict) -> dict: if "restrictToWorkspace" in exec_cfg and "restrictToWorkspace" not in tools: tools["restrictToWorkspace"] = exec_cfg.pop("restrictToWorkspace") return data - - -def convert_keys(data: Any) -> Any: - """Convert camelCase keys to snake_case for Pydantic.""" - if isinstance(data, dict): - return {camel_to_snake(k): convert_keys(v) for k, v in data.items()} - if isinstance(data, list): - return [convert_keys(item) for item in data] - return data - - -def convert_to_camel(data: Any) -> Any: - """Convert snake_case keys to camelCase.""" - if isinstance(data, dict): - return {snake_to_camel(k): convert_to_camel(v) for k, v in data.items()} - if isinstance(data, list): - return [convert_to_camel(item) for item in data] - return data - - -def camel_to_snake(name: str) -> str: - """Convert camelCase to snake_case.""" - result = [] - for i, char in enumerate(name): - if char.isupper() and i > 0: - result.append("_") - result.append(char.lower()) - return "".join(result) - - -def snake_to_camel(name: str) -> str: - """Convert snake_case to camelCase.""" - components = name.split("_") - return components[0] + "".join(x.title() for x in components[1:]) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 64609ec..0786080 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -2,27 +2,39 @@ from pathlib import Path from pydantic import BaseModel, Field, ConfigDict +from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings -class WhatsAppConfig(BaseModel): +class Base(BaseModel): + """Base model that accepts both camelCase and snake_case keys.""" + + model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True) + + +class WhatsAppConfig(Base): """WhatsApp channel configuration.""" + enabled: bool = False bridge_url: str = "ws://localhost:3001" bridge_token: str = "" # Shared token for bridge auth (optional, recommended) allow_from: list[str] = Field(default_factory=list) # Allowed phone numbers -class TelegramConfig(BaseModel): +class TelegramConfig(Base): """Telegram channel configuration.""" + enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + proxy: str | None = ( + None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" + ) -class FeishuConfig(BaseModel): +class FeishuConfig(Base): """Feishu/Lark channel configuration using WebSocket long connection.""" + enabled: bool = False app_id: str = "" # App ID from Feishu Open Platform app_secret: str = "" # App Secret from Feishu Open Platform @@ -31,24 +43,28 @@ class FeishuConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids -class DingTalkConfig(BaseModel): +class DingTalkConfig(Base): """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): +class DiscordConfig(Base): """Discord channel configuration.""" + enabled: bool = False token: str = "" # Bot token from Discord Developer Portal allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT -class EmailConfig(BaseModel): + +class EmailConfig(Base): """Email channel configuration (IMAP inbound + SMTP outbound).""" + enabled: bool = False consent_granted: bool = False # Explicit owner permission to access mailbox data @@ -70,7 +86,9 @@ class EmailConfig(BaseModel): from_address: str = "" # Behavior - auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent + 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 @@ -78,18 +96,21 @@ class EmailConfig(BaseModel): allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses -class MochatMentionConfig(BaseModel): +class MochatMentionConfig(Base): """Mochat mention behavior configuration.""" + require_in_groups: bool = False -class MochatGroupRule(BaseModel): +class MochatGroupRule(Base): """Mochat per-group mention requirement.""" + require_mention: bool = False -class MochatConfig(BaseModel): +class MochatConfig(Base): """Mochat channel configuration.""" + enabled: bool = False base_url: str = "https://mochat.io" socket_url: str = "" @@ -114,15 +135,17 @@ class MochatConfig(BaseModel): reply_delay_ms: int = 120000 -class SlackDMConfig(BaseModel): +class SlackDMConfig(Base): """Slack DM policy configuration.""" + enabled: bool = True policy: str = "open" # "open" or "allowlist" allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs -class SlackConfig(BaseModel): +class SlackConfig(Base): """Slack channel configuration.""" + enabled: bool = False mode: str = "socket" # "socket" supported webhook_path: str = "/slack/events" @@ -134,16 +157,20 @@ class SlackConfig(BaseModel): dm: SlackDMConfig = Field(default_factory=SlackDMConfig) -class QQConfig(BaseModel): +class QQConfig(Base): """QQ channel configuration using botpy SDK.""" + enabled: bool = False app_id: str = "" # ζœΊε™¨δΊΊ ID (AppID) from q.qq.com secret: str = "" # ζœΊε™¨δΊΊε―†ι’₯ (AppSecret) from q.qq.com - allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) + allow_from: list[str] = Field( + default_factory=list + ) # Allowed user openids (empty = public access) -class ChannelsConfig(BaseModel): +class ChannelsConfig(Base): """Configuration for chat channels.""" + whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) @@ -155,8 +182,9 @@ class ChannelsConfig(BaseModel): qq: QQConfig = Field(default_factory=QQConfig) -class AgentDefaults(BaseModel): +class AgentDefaults(Base): """Default agent configuration.""" + workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" max_tokens: int = 8192 @@ -165,20 +193,23 @@ class AgentDefaults(BaseModel): memory_window: int = 50 -class AgentsConfig(BaseModel): +class AgentsConfig(Base): """Agent configuration.""" + defaults: AgentDefaults = Field(default_factory=AgentDefaults) -class ProviderConfig(BaseModel): +class ProviderConfig(Base): """LLM provider configuration.""" + api_key: str = "" api_base: str | None = None extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) -class ProvidersConfig(BaseModel): +class ProvidersConfig(Base): """Configuration for LLM providers.""" + custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) @@ -196,38 +227,44 @@ class ProvidersConfig(BaseModel): github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) -class GatewayConfig(BaseModel): +class GatewayConfig(Base): """Gateway/server configuration.""" + host: str = "0.0.0.0" port: int = 18790 -class WebSearchConfig(BaseModel): +class WebSearchConfig(Base): """Web search tool configuration.""" + api_key: str = "" # Brave Search API key max_results: int = 5 -class WebToolsConfig(BaseModel): +class WebToolsConfig(Base): """Web tools configuration.""" + search: WebSearchConfig = Field(default_factory=WebSearchConfig) -class ExecToolConfig(BaseModel): +class ExecToolConfig(Base): """Shell exec tool configuration.""" + timeout: int = 60 -class MCPServerConfig(BaseModel): +class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" + command: str = "" # Stdio: command to run (e.g. "npx") args: list[str] = Field(default_factory=list) # Stdio: command arguments env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars url: str = "" # HTTP: streamable HTTP endpoint URL -class ToolsConfig(BaseModel): +class ToolsConfig(Base): """Tools configuration.""" + web: WebToolsConfig = Field(default_factory=WebToolsConfig) exec: ExecToolConfig = Field(default_factory=ExecToolConfig) restrict_to_workspace: bool = False # If true, restrict all tool access to workspace directory @@ -236,20 +273,24 @@ class ToolsConfig(BaseModel): class Config(BaseSettings): """Root configuration for nanobot.""" + agents: AgentsConfig = Field(default_factory=AgentsConfig) channels: ChannelsConfig = Field(default_factory=ChannelsConfig) providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) - + @property def workspace_path(self) -> Path: """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - - def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: + + 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() # Match by keyword (order follows PROVIDERS registry) @@ -283,10 +324,11 @@ class Config(BaseSettings): """Get API key for the given model. Falls back to first available key.""" p = self.get_provider(model) return p.api_key if p else None - + 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 find_by_name + p, name = self._match_provider(model) if p and p.api_base: return p.api_base @@ -298,8 +340,5 @@ class Config(BaseSettings): if spec and spec.is_gateway and spec.default_api_base: return spec.default_api_base return None - - model_config = ConfigDict( - env_prefix="NANOBOT_", - env_nested_delimiter="__" - ) + + model_config = ConfigDict(env_prefix="NANOBOT_", env_nested_delimiter="__") From 941c3d98264303c7494b98c306c2696499bdc45a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 17 Feb 2026 17:34:24 +0000 Subject: [PATCH 13/13] style: restore single-line formatting for readability --- nanobot/config/schema.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 0786080..76ec74d 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -27,9 +27,7 @@ class TelegramConfig(Base): enabled: bool = False token: str = "" # Bot token from @BotFather allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames - proxy: str | None = ( - None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" - ) + proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" class FeishuConfig(Base): @@ -86,9 +84,7 @@ class EmailConfig(Base): from_address: str = "" # Behavior - auto_reply_enabled: bool = ( - True # If false, inbound email is read but no automatic reply is sent - ) + 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 @@ -163,9 +159,7 @@ class QQConfig(Base): enabled: bool = False app_id: str = "" # ζœΊε™¨δΊΊ ID (AppID) from q.qq.com secret: str = "" # ζœΊε™¨δΊΊε―†ι’₯ (AppSecret) from q.qq.com - allow_from: list[str] = Field( - default_factory=list - ) # Allowed user openids (empty = public access) + allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access) class ChannelsConfig(Base): @@ -285,9 +279,7 @@ class Config(BaseSettings): """Get expanded workspace path.""" return Path(self.agents.defaults.workspace).expanduser() - def _match_provider( - self, model: str | None = None - ) -> tuple["ProviderConfig | None", str | None]: + 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