From 59017aa9bb9d8d114c7f5345d831eac81e81ed43 Mon Sep 17 00:00:00 2001 From: "tao.jun" <61566027@163.com> Date: Sun, 8 Feb 2026 13:03:32 +0800 Subject: [PATCH 001/113] feat(feishu): Add event handlers for reactions, message read, and p2p chat events - Register handlers for message reaction created events - Register handlers for message read events - Register handlers for bot entering p2p chat events - Prevent error logs for these common but unprocessed events - Import required event types from lark_oapi --- nanobot/channels/feishu.py | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 1c176a2..a4c7454 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -23,6 +23,8 @@ try: CreateMessageReactionRequestBody, Emoji, P2ImMessageReceiveV1, + P2ImMessageMessageReadV1, + P2ImMessageReactionCreatedV1, ) FEISHU_AVAILABLE = True except ImportError: @@ -82,12 +84,18 @@ class FeishuChannel(BaseChannel): .log_level(lark.LogLevel.INFO) \ .build() - # Create event handler (only register message receive, ignore other events) + # Create event handler (register message receive and other common events) event_handler = lark.EventDispatcherHandler.builder( self.config.encrypt_key or "", self.config.verification_token or "", ).register_p2_im_message_receive_v1( self._on_message_sync + ).register_p2_im_message_reaction_created_v1( + self._on_reaction_created + ).register_p2_im_message_message_read_v1( + self._on_message_read + ).register_p2_im_chat_access_event_bot_p2p_chat_entered_v1( + self._on_bot_p2p_chat_entered ).build() # Create WebSocket client for long connection @@ -305,3 +313,26 @@ class FeishuChannel(BaseChannel): except Exception as e: logger.error(f"Error processing Feishu message: {e}") + + def _on_reaction_created(self, data: "P2ImMessageReactionCreatedV1") -> None: + """ + Handler for message reaction events. + We don't need to process these, but registering prevents error logs. + """ + pass + + def _on_message_read(self, data: "P2ImMessageMessageReadV1") -> None: + """ + Handler for message read events. + We don't need to process these, but registering prevents error logs. + """ + pass + + def _on_bot_p2p_chat_entered(self, data: Any) -> None: + """ + Handler for bot entering p2p chat events. + This is triggered when a user opens a chat with the bot. + We don't need to process these, but registering prevents error logs. + """ + logger.debug("Bot entered p2p chat (user opened chat window)") + pass From 4d6f02ec0dee02f532df2295e76ea7c6c2b15ae5 Mon Sep 17 00:00:00 2001 From: eric Date: Mon, 9 Feb 2026 21:12:16 -0500 Subject: [PATCH 002/113] fix(telegram): preserve file extension for generic documents --- nanobot/channels/telegram.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ff46c86..99c64d4 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -309,7 +309,11 @@ class TelegramChannel(BaseChannel): if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) - ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) + ext = self._get_extension( + media_type, + getattr(media_file, 'mime_type', None), + getattr(media_file, 'file_name', None) + ) # Save to workspace/media/ from pathlib import Path @@ -386,8 +390,12 @@ class TelegramChannel(BaseChannel): except Exception as e: logger.debug(f"Typing indicator stopped for {chat_id}: {e}") - def _get_extension(self, media_type: str, mime_type: str | None) -> str: - """Get file extension based on media type.""" + def _get_extension(self, media_type: str, mime_type: str | None, filename: str | None = None) -> str: + """ + Get file extension based on media type. + If mime_type is unknown, try to get extension from filename. + """ + # 1. Try known mime types if mime_type: ext_map = { "image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", @@ -396,5 +404,14 @@ class TelegramChannel(BaseChannel): if mime_type in ext_map: return ext_map[mime_type] - type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} - return type_map.get(media_type, "") + # 2. Try simple type mapping + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3"} + if media_type in type_map: + return type_map[media_type] + + # 3. Fallback: try to get extension from filename + if filename: + from pathlib import Path + return Path(filename).suffix + + return "" From 039ab717fa29258d250f011905f31744cee5879c Mon Sep 17 00:00:00 2001 From: zhengliyuan Date: Thu, 12 Feb 2026 10:44:26 +0800 Subject: [PATCH 003/113] update: Enable listening to both private and group messages. --- nanobot/channels/qq.py | 80 +++++++++++++++++++++++++++++++----------- 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 5964d30..8a74261 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -2,7 +2,7 @@ import asyncio from collections import deque -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Dict from loguru import logger @@ -13,20 +13,22 @@ from nanobot.config.schema import QQConfig try: import botpy - from botpy.message import C2CMessage + from botpy.message import C2CMessage, GroupMessage # 1. Import GroupMessage QQ_AVAILABLE = True except ImportError: QQ_AVAILABLE = False botpy = None C2CMessage = None + GroupMessage = None if TYPE_CHECKING: - from botpy.message import C2CMessage + from botpy.message import C2CMessage, GroupMessage def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": """Create a botpy Client subclass bound to the given channel.""" + # 2. Ensure intents enable public_messages (required for group messages) intents = botpy.Intents(public_messages=True, direct_message=True) class _Bot(botpy.Client): @@ -37,10 +39,17 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": logger.info(f"QQ bot ready: {self.robot.name}") async def on_c2c_message_create(self, message: "C2CMessage"): - await channel._on_message(message) + # C2C (Private) message + await channel._on_message(message, is_group=False) + + async def on_group_at_message_create(self, message: "GroupMessage"): + # 3. Added: Listen for group @messages + # Note: Official bots only receive messages @mentioning them unless privileged + await channel._on_message(message, is_group=True) async def on_direct_message_create(self, message): - await channel._on_message(message) + # Guild Direct Message + await channel._on_message(message, is_group=False) return _Bot @@ -56,6 +65,9 @@ class QQChannel(BaseChannel): self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) self._bot_task: asyncio.Task | None = None + # Cache to track if chat_id is a group or individual to select the correct reply API + # Format: {chat_id: "group" | "c2c"} + self._chat_type_cache: Dict[str, str] = {} async def start(self) -> None: """Start the QQ bot.""" @@ -72,14 +84,14 @@ class QQChannel(BaseChannel): self._client = BotClass() self._bot_task = asyncio.create_task(self._run_bot()) - logger.info("QQ bot started (C2C private message)") + logger.info("QQ bot started (C2C & Group supported)") async def _run_bot(self) -> None: """Run the bot connection.""" try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}") + logger.error(f"QQ auth failed: {e}") self._running = False async def stop(self) -> None: @@ -98,16 +110,31 @@ class QQChannel(BaseChannel): if not self._client: logger.warning("QQ client not initialized") return - try: - await self._client.api.post_c2c_message( - openid=msg.chat_id, - msg_type=0, - content=msg.content, - ) - except Exception as e: - logger.error(f"Error sending QQ message: {e}") + + # 4. Modified send logic: Check chat_id type to call the correct API + msg_type = self._chat_type_cache.get(msg.chat_id, "c2c") # Default to c2c - async def _on_message(self, data: "C2CMessage") -> None: + try: + if msg_type == "group": + # Send group message + await self._client.api.post_group_message( + group_openid=msg.chat_id, + msg_type=0, + msg_id=msg.metadata.get("message_id"), # Reply to specific message ID (optional but recommended) + content=msg.content + ) + else: + # Send C2C (private) message + await self._client.api.post_c2c_message( + openid=msg.chat_id, + msg_type=0, + msg_id=msg.metadata.get("message_id"), + content=msg.content, + ) + except Exception as e: + logger.error(f"Error sending QQ message ({msg_type}): {e}") + + async def _on_message(self, data: "C2CMessage | GroupMessage", is_group: bool = False) -> None: """Handle incoming message from QQ.""" try: # Dedup by message ID @@ -115,17 +142,30 @@ class QQChannel(BaseChannel): return self._processed_ids.append(data.id) - author = data.author - user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown')) content = (data.content or "").strip() if not content: return + # 5. Extract ID and cache type + if is_group: + # Group message: chat_id uses group_openid + chat_id = data.group_openid + user_id = data.author.member_openid # Sender's ID + self._chat_type_cache[chat_id] = "group" + + # Remove @bot text (optional, prevents Nanobot from treating the name as prompt) + # content = content.replace("@BotName", "").strip() + else: + # Private message: chat_id uses user_openid + chat_id = str(getattr(data.author, 'id', None) or getattr(data.author, 'user_openid', 'unknown')) + user_id = chat_id + self._chat_type_cache[chat_id] = "c2c" + await self._handle_message( sender_id=user_id, - chat_id=user_id, + chat_id=chat_id, content=content, metadata={"message_id": data.id}, ) except Exception as e: - logger.error(f"Error handling QQ message: {e}") + logger.error(f"Error handling QQ message: {e}") \ No newline at end of file From d07e0c1f79eff656b513c4aadba25e14d4b598cd Mon Sep 17 00:00:00 2001 From: "Aleksander W. Oleszkiewicz (Alek)" <24917047+alekwo@users.noreply.github.com> Date: Sun, 15 Feb 2026 13:51:17 +0100 Subject: [PATCH 004/113] Update Slack message text fallback response Slack doesn't accept an empty string in the `text` parameter. However, Nanobot sometimes sends an empty response. This may need a change in the bot's logic as well; still, it should also be handled by the channel. I suggest changing the default message to '' when the content is empty, so the user will know that the bot was trying to respond with an empty message. --- nanobot/channels/slack.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index be95dd2..fb86b3a 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -84,7 +84,7 @@ class SlackChannel(BaseChannel): use_thread = thread_ts and channel_type != "im" await self._web_client.chat_postMessage( channel=msg.chat_id, - text=msg.content or "", + text=msg.content or "", thread_ts=thread_ts if use_thread else None, ) except Exception as e: From 8571df2e634809b396b08b0967957078c15ede7f Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sun, 1 Mar 2026 15:13:44 +0100 Subject: [PATCH 005/113] fix(feishu): split card messages when content has multiple tables Feishu rejects interactive cards that contain more than one table element (API error 11310: card table number over limit). Add FeishuChannel._split_elements_by_table_limit() which partitions the flat card-elements list into groups of at most one table each. The send() method now iterates over these groups and sends each as its own card message, so all tables are delivered to the user instead of the entire message being dropped. Single-table and table-free messages are unaffected (one card, same as before). Fixes #1382 --- nanobot/channels/feishu.py | 40 ++++++++++-- tests/test_feishu_table_split.py | 104 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 tests/test_feishu_table_split.py diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..9ab1d50 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -413,6 +413,34 @@ class FeishuChannel(BaseChannel): elements.extend(self._split_headings(remaining)) return elements or [{"tag": "markdown", "content": content}] + @staticmethod + def _split_elements_by_table_limit(elements: list[dict], max_tables: int = 1) -> list[list[dict]]: + """Split card elements into groups with at most *max_tables* table elements each. + + Feishu cards have a hard limit of one table per card (API error 11310). + When the rendered content contains multiple markdown tables each table is + placed in a separate card message so every table reaches the user. + """ + if not elements: + return [[]] + groups: list[list[dict]] = [] + current: list[dict] = [] + table_count = 0 + for el in elements: + if el.get("tag") == "table": + if table_count >= max_tables: + if current: + groups.append(current) + current = [] + table_count = 0 + current.append(el) + table_count += 1 + else: + current.append(el) + if current: + groups.append(current) + return groups or [[]] + def _split_headings(self, content: str) -> list[dict]: """Split content by headings, converting headings to div elements.""" protected = content @@ -653,11 +681,13 @@ class FeishuChannel(BaseChannel): ) if msg.content and msg.content.strip(): - card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)} - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), - ) + elements = self._build_card_elements(msg.content) + for chunk in self._split_elements_by_table_limit(elements): + card = {"config": {"wide_screen_mode": True}, "elements": chunk} + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + ) except Exception as e: logger.error("Error sending Feishu message: {}", e) diff --git a/tests/test_feishu_table_split.py b/tests/test_feishu_table_split.py new file mode 100644 index 0000000..af8fa16 --- /dev/null +++ b/tests/test_feishu_table_split.py @@ -0,0 +1,104 @@ +"""Tests for FeishuChannel._split_elements_by_table_limit. + +Feishu cards reject messages that contain more than one table element +(API error 11310: card table number over limit). The helper splits a flat +list of card elements into groups so that each group contains at most one +table, allowing nanobot to send multiple cards instead of failing. +""" + +from nanobot.channels.feishu import FeishuChannel + + +def _md(text: str) -> dict: + return {"tag": "markdown", "content": text} + + +def _table() -> dict: + return { + "tag": "table", + "columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}], + "rows": [{"c0": "v"}], + "page_size": 2, + } + + +split = FeishuChannel._split_elements_by_table_limit + + +def test_empty_list_returns_single_empty_group() -> None: + assert split([]) == [[]] + + +def test_no_tables_returns_single_group() -> None: + els = [_md("hello"), _md("world")] + result = split(els) + assert result == [els] + + +def test_single_table_stays_in_one_group() -> None: + els = [_md("intro"), _table(), _md("outro")] + result = split(els) + assert len(result) == 1 + assert result[0] == els + + +def test_two_tables_split_into_two_groups() -> None: + # Use different row values so the two tables are not equal + t1 = { + "tag": "table", + "columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}], + "rows": [{"c0": "table-one"}], + "page_size": 2, + } + t2 = { + "tag": "table", + "columns": [{"tag": "column", "name": "c0", "display_name": "B", "width": "auto"}], + "rows": [{"c0": "table-two"}], + "page_size": 2, + } + els = [_md("before"), t1, _md("between"), t2, _md("after")] + result = split(els) + assert len(result) == 2 + # First group: text before table-1 + table-1 + assert t1 in result[0] + assert t2 not in result[0] + # Second group: text between tables + table-2 + text after + assert t2 in result[1] + assert t1 not in result[1] + + +def test_three_tables_split_into_three_groups() -> None: + tables = [ + {"tag": "table", "columns": [], "rows": [{"c0": f"t{i}"}], "page_size": 1} + for i in range(3) + ] + els = tables[:] + result = split(els) + assert len(result) == 3 + for i, group in enumerate(result): + assert tables[i] in group + + +def test_leading_markdown_stays_with_first_table() -> None: + intro = _md("intro") + t = _table() + result = split([intro, t]) + assert len(result) == 1 + assert result[0] == [intro, t] + + +def test_trailing_markdown_after_second_table() -> None: + t1, t2 = _table(), _table() + tail = _md("end") + result = split([t1, t2, tail]) + assert len(result) == 2 + assert result[1] == [t2, tail] + + +def test_non_table_elements_before_first_table_kept_in_first_group() -> None: + head = _md("head") + t1, t2 = _table(), _table() + result = split([head, t1, t2]) + # head + t1 in group 0; t2 in group 1 + assert result[0] == [head, t1] + assert result[1] == [t2] From 43329018f78826770f28674bf0c01643414f65fe Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:50:02 +0000 Subject: [PATCH 006/113] fix(telegram): add group_policy config for Telegram groups Add `group_policy` field to `TelegramConfig` with "open" (default) and "mention" options, consistent with Slack and Matrix channel configs. --- nanobot/config/schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6b80c81..69cf826 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -31,6 +31,7 @@ class TelegramConfig(Base): 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" reply_to_message: bool = False # If true, bot replies quote the original message + group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned or replied to class FeishuConfig(Base): From 521217a7f50f0a8de46a88e101c8e9bf16abae27 Mon Sep 17 00:00:00 2001 From: Sense_wang <167664334+haosenwang1018@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:50:36 +0000 Subject: [PATCH 007/113] fix(telegram): enforce group_policy in _on_message When `group_policy` is set to "mention", skip messages in group chats unless the bot is @mentioned or the message is a reply to the bot. Fixes #1380 --- nanobot/channels/telegram.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..3702666 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -341,6 +341,23 @@ class TelegramChannel(BaseChannel): # Store chat_id for replies self._chat_ids[sender_id] = chat_id + # Enforce group_policy: in group chats with "mention" policy, + # only respond when the bot is @mentioned or the message is a reply to the bot. + is_group = message.chat.type != "private" + if is_group and getattr(self.config, "group_policy", "open") == "mention": + bot_username = (await self._app.bot.get_me()).username if self._app else None + mentioned = False + # Check if bot is @mentioned in text + if bot_username and message.text: + mentioned = f"@{bot_username}" in message.text + # Check if the message is a reply to the bot + if not mentioned and message.reply_to_message and message.reply_to_message.from_user: + bot_id = (await self._app.bot.get_me()).id if self._app else None + if bot_id and message.reply_to_message.from_user.id == bot_id: + mentioned = True + if not mentioned: + return + # Build content from text and/or media content_parts = [] media_paths = [] From ae788a17f8371de0d60b7b9d713bdb8261fa6cd2 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 11:03:54 +0800 Subject: [PATCH 008/113] chore: add .worktrees to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d7b930d..a543251 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.worktrees/ .assets .env *.pyc From aed1ef55298433a963474d8fbdcf0b203945ffb5 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 11:04:53 +0800 Subject: [PATCH 009/113] fix: add SIGTERM, SIGHUP handling and ignore SIGPIPE - Add handler for SIGTERM to prevent "Terminated" message on Linux - Add handler for SIGHUP for terminal closure handling - Ignore SIGPIPE to prevent silent process termination - Change os._exit(0) to sys.exit(0) for proper cleanup Fixes issue #1365 Co-Authored-By: Claude Opus 4.6 --- nanobot/cli/commands.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2662e9f..8c53992 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -501,12 +501,17 @@ def agent( else: cli_channel, cli_chat_id = "cli", session_id - def _exit_on_sigint(signum, frame): + def _handle_signal(signum, frame): + sig_name = signal.Signals(signum).name _restore_terminal() - console.print("\nGoodbye!") - os._exit(0) + console.print(f"\nReceived {sig_name}, goodbye!") + sys.exit(0) - signal.signal(signal.SIGINT, _exit_on_sigint) + signal.signal(signal.SIGINT, _handle_signal) + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGHUP, _handle_signal) + # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes + signal.signal(signal.SIGPIPE, signal.SIG_IGN) async def run_interactive(): bus_task = asyncio.create_task(agent_loop.run()) From e9d023f52cbc7fb8eab37ab5aa4a501b0b5bdc81 Mon Sep 17 00:00:00 2001 From: Joel Chan Date: Thu, 12 Feb 2026 17:10:50 +0800 Subject: [PATCH 010/113] feat(discord): add group policy to control group respond behaviour --- README.md | 8 +++++++- nanobot/channels/discord.py | 36 +++++++++++++++++++++++++++++++++++- nanobot/config/schema.py | 1 + 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 45779e7..f141a1c 100644 --- a/README.md +++ b/README.md @@ -293,12 +293,18 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allowFrom": ["YOUR_USER_ID"], + "groupPolicy": "mention" } } } ``` +> `groupPolicy` controls how the bot responds in group channels: +> - `"mention"` (default) โ€” Only respond when @mentioned +> - `"open"` โ€” Respond to all messages +> DMs always respond when the sender is in `allowFrom`. + **5. Invite the bot** - OAuth2 โ†’ URL Generator - Scopes: `bot` diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 57e5922..85ff28a 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -54,6 +54,7 @@ class DiscordChannel(BaseChannel): self._heartbeat_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None + self._bot_user_id: str | None = None async def start(self) -> None: """Start the Discord gateway connection.""" @@ -170,6 +171,10 @@ class DiscordChannel(BaseChannel): await self._identify() elif op == 0 and event_type == "READY": logger.info("Discord gateway READY") + # Capture bot user ID for mention detection + user_data = payload.get("user") or {} + self._bot_user_id = user_data.get("id") + logger.info(f"Discord bot connected as user {self._bot_user_id}") elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) elif op == 7: @@ -226,6 +231,7 @@ class DiscordChannel(BaseChannel): sender_id = str(author.get("id", "")) channel_id = str(payload.get("channel_id", "")) content = payload.get("content") or "" + guild_id = payload.get("guild_id") if not sender_id or not channel_id: return @@ -233,6 +239,11 @@ class DiscordChannel(BaseChannel): if not self.is_allowed(sender_id): return + # Check group channel policy (DMs always respond if is_allowed passes) + if guild_id is not None: + if not self._should_respond_in_group(payload, content): + return + content_parts = [content] if content else [] media_paths: list[str] = [] media_dir = Path.home() / ".nanobot" / "media" @@ -269,11 +280,34 @@ class DiscordChannel(BaseChannel): media=media_paths, metadata={ "message_id": str(payload.get("id", "")), - "guild_id": payload.get("guild_id"), + "guild_id": guild_id, "reply_to": reply_to, }, ) + def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: + """Check if bot should respond in a group channel based on policy.""" + channel_id = str(payload.get("channel_id", "")) + + if self.config.group_policy == "open": + return True + + if self.config.group_policy == "mention": + # Check if bot was mentioned in the message + if self._bot_user_id: + # Check mentions array + mentions = payload.get("mentions") or [] + for mention in mentions: + if str(mention.get("id")) == self._bot_user_id: + return True + # Also check content for mention format <@USER_ID> + if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content: + return True + logger.debug(f"Discord message in {channel_id} ignored (bot not mentioned)") + return False + + return True + async def _start_typing(self, channel_id: str) -> None: """Start periodic typing indicator for a channel.""" await self._stop_typing(channel_id) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 6b80c81..e3d3d23 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -62,6 +62,7 @@ class DiscordConfig(Base): 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 + group_policy: str = "open" # "mention" or "open" class MatrixConfig(Base): From ecdfaf0a5a00b0772719aa306d4cb36d8512f9c7 Mon Sep 17 00:00:00 2001 From: David Markey Date: Sun, 1 Mar 2026 20:49:00 +0000 Subject: [PATCH 011/113] feat(custom-provider): add x-session-affinity header for prompt caching --- nanobot/providers/custom_provider.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 56e6270..02183f3 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from typing import Any import json_repair @@ -15,7 +16,11 @@ class CustomProvider(LLMProvider): def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): super().__init__(api_key, api_base) self.default_model = default_model - self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) + self._client = AsyncOpenAI( + api_key=api_key, + base_url=api_base, + default_headers={"x-session-affinity": uuid.uuid4().hex}, + ) async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, From 8f4baaa5ce750fc073921fa29e734a0fe0da2056 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 22:01:02 +0800 Subject: [PATCH 012/113] feat(gateway): support multiple instances with --workspace and --config options - Add --workspace/-w flag to specify workspace directory - Add --config/-c flag to specify config file path - Move cron store to workspace directory for per-instance isolation - Enable running multiple nanobot instances simultaneously --- README.md | 27 +++++++++++++++++++++++++++ nanobot/cli/commands.py | 13 ++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 01da228..3022708 100644 --- a/README.md +++ b/README.md @@ -884,6 +884,33 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | +## Multiple Instances + +Run multiple nanobot instances simultaneously, each with its own workspace and configuration. + +```bash +# Instance A - Telegram bot +nanobot gateway -w ~/.nanobot/botA -p 18791 + +# Instance B - Discord bot +nanobot gateway -w ~/.nanobot/botB -p 18792 + +# Instance C - Using custom config file +nanobot gateway -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json -p 18793 +``` + +| Option | Short | Description | +|--------|-------|-------------| +| `--workspace` | `-w` | Workspace directory (default: `~/.nanobot/workspace`) | +| `--config` | `-c` | Config file path (default: `~/.nanobot/config.json`) | +| `--port` | `-p` | Gateway port (default: `18790`) | + +Each instance has its own: +- Workspace directory (MEMORY.md, HEARTBEAT.md, session files) +- Cron jobs storage (`workspace/cron/jobs.json`) +- Configuration (if using `--config`) + + ## CLI Reference | Command | Description | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2662e9f..e599b11 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -244,6 +244,8 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory (default: ~/.nanobot/workspace)"), + config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): """Start the nanobot gateway.""" @@ -260,6 +262,14 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) + # Load config from custom path if provided, otherwise use default + config_path = Path(config) if config else None + config = load_config(config_path) + + # Override workspace if specified via command line + if workspace: + config.agents.defaults.workspace = workspace + console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() @@ -269,7 +279,8 @@ def gateway( session_manager = SessionManager(config.workspace_path) # Create cron service first (callback set after agent creation) - cron_store_path = get_data_dir() / "cron" / "jobs.json" + # Use workspace path for per-instance cron store + cron_store_path = config.workspace_path / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service From a7be0b3c9eaf967c0079ab1c1a08be4f2010fc09 Mon Sep 17 00:00:00 2001 From: Yan-ke Guo Date: Tue, 3 Mar 2026 18:14:26 +0800 Subject: [PATCH 013/113] sync missing scripts from upstream openclaw repository --- nanobot/skills/skill-creator/SKILL.md | 3 +- .../skill-creator/scripts/init_skill.py | 378 ++++++++++++++++++ .../skill-creator/scripts/package_skill.py | 139 +++++++ 3 files changed, 519 insertions(+), 1 deletion(-) create mode 100755 nanobot/skills/skill-creator/scripts/init_skill.py create mode 100755 nanobot/skills/skill-creator/scripts/package_skill.py diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index 9b5eb6f..f4d6e0b 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -349,7 +349,6 @@ scripts/package_skill.py ./dist The packaging script will: 1. **Validate** the skill automatically, checking: - - YAML frontmatter format and required fields - Skill naming conventions and directory structure - Description completeness and quality @@ -357,6 +356,8 @@ The packaging script will: 2. **Package** the skill if validation passes, creating a .skill file named after the skill (e.g., `my-skill.skill`) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension. + Security restriction: symlinks are rejected and packaging fails when any symlink is present. + If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again. ### Step 6: Iterate diff --git a/nanobot/skills/skill-creator/scripts/init_skill.py b/nanobot/skills/skill-creator/scripts/init_skill.py new file mode 100755 index 0000000..8633fe9 --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/init_skill.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +""" +Skill Initializer - Creates a new skill from template + +Usage: + init_skill.py --path [--resources scripts,references,assets] [--examples] + +Examples: + init_skill.py my-new-skill --path skills/public + init_skill.py my-new-skill --path skills/public --resources scripts,references + init_skill.py my-api-helper --path skills/private --resources scripts --examples + init_skill.py custom-skill --path /custom/location +""" + +import argparse +import re +import sys +from pathlib import Path + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_RESOURCES = {"scripts", "references", "assets"} + +SKILL_TEMPLATE = """--- +name: {skill_name} +description: [TODO: Complete and informative explanation of what the skill does and when to use it. Include WHEN to use this skill - specific scenarios, file types, or tasks that trigger it.] +--- + +# {skill_title} + +## Overview + +[TODO: 1-2 sentences explaining what this skill enables] + +## Structuring This Skill + +[TODO: Choose the structure that best fits this skill's purpose. Common patterns: + +**1. Workflow-Based** (best for sequential processes) +- Works well when there are clear step-by-step procedures +- Example: DOCX skill with "Workflow Decision Tree" -> "Reading" -> "Creating" -> "Editing" +- Structure: ## Overview -> ## Workflow Decision Tree -> ## Step 1 -> ## Step 2... + +**2. Task-Based** (best for tool collections) +- Works well when the skill offers different operations/capabilities +- Example: PDF skill with "Quick Start" -> "Merge PDFs" -> "Split PDFs" -> "Extract Text" +- Structure: ## Overview -> ## Quick Start -> ## Task Category 1 -> ## Task Category 2... + +**3. Reference/Guidelines** (best for standards or specifications) +- Works well for brand guidelines, coding standards, or requirements +- Example: Brand styling with "Brand Guidelines" -> "Colors" -> "Typography" -> "Features" +- Structure: ## Overview -> ## Guidelines -> ## Specifications -> ## Usage... + +**4. Capabilities-Based** (best for integrated systems) +- Works well when the skill provides multiple interrelated features +- Example: Product Management with "Core Capabilities" -> numbered capability list +- Structure: ## Overview -> ## Core Capabilities -> ### 1. Feature -> ### 2. Feature... + +Patterns can be mixed and matched as needed. Most skills combine patterns (e.g., start with task-based, add workflow for complex operations). + +Delete this entire "Structuring This Skill" section when done - it's just guidance.] + +## [TODO: Replace with the first main section based on chosen structure] + +[TODO: Add content here. See examples in existing skills: +- Code samples for technical skills +- Decision trees for complex workflows +- Concrete examples with realistic user requests +- References to scripts/templates/references as needed] + +## Resources (optional) + +Create only the resource directories this skill actually needs. Delete this section if no resources are required. + +### scripts/ +Executable code (Python/Bash/etc.) that can be run directly to perform specific operations. + +**Examples from other skills:** +- PDF skill: `fill_fillable_fields.py`, `extract_form_field_info.py` - utilities for PDF manipulation +- DOCX skill: `document.py`, `utilities.py` - Python modules for document processing + +**Appropriate for:** Python scripts, shell scripts, or any executable code that performs automation, data processing, or specific operations. + +**Note:** Scripts may be executed without loading into context, but can still be read by Codex for patching or environment adjustments. + +### references/ +Documentation and reference material intended to be loaded into context to inform Codex's process and thinking. + +**Examples from other skills:** +- Product management: `communication.md`, `context_building.md` - detailed workflow guides +- BigQuery: API reference documentation and query examples +- Finance: Schema documentation, company policies + +**Appropriate for:** In-depth documentation, API references, database schemas, comprehensive guides, or any detailed information that Codex should reference while working. + +### assets/ +Files not intended to be loaded into context, but rather used within the output Codex produces. + +**Examples from other skills:** +- Brand styling: PowerPoint template files (.pptx), logo files +- Frontend builder: HTML/React boilerplate project directories +- Typography: Font files (.ttf, .woff2) + +**Appropriate for:** Templates, boilerplate code, document templates, images, icons, fonts, or any files meant to be copied or used in the final output. + +--- + +**Not every skill requires all three types of resources.** +""" + +EXAMPLE_SCRIPT = '''#!/usr/bin/env python3 +""" +Example helper script for {skill_name} + +This is a placeholder script that can be executed directly. +Replace with actual implementation or delete if not needed. + +Example real scripts from other skills: +- pdf/scripts/fill_fillable_fields.py - Fills PDF form fields +- pdf/scripts/convert_pdf_to_images.py - Converts PDF pages to images +""" + +def main(): + print("This is an example script for {skill_name}") + # TODO: Add actual script logic here + # This could be data processing, file conversion, API calls, etc. + +if __name__ == "__main__": + main() +''' + +EXAMPLE_REFERENCE = """# Reference Documentation for {skill_title} + +This is a placeholder for detailed reference documentation. +Replace with actual reference content or delete if not needed. + +Example real reference docs from other skills: +- product-management/references/communication.md - Comprehensive guide for status updates +- product-management/references/context_building.md - Deep-dive on gathering context +- bigquery/references/ - API references and query examples + +## When Reference Docs Are Useful + +Reference docs are ideal for: +- Comprehensive API documentation +- Detailed workflow guides +- Complex multi-step processes +- Information too lengthy for main SKILL.md +- Content that's only needed for specific use cases + +## Structure Suggestions + +### API Reference Example +- Overview +- Authentication +- Endpoints with examples +- Error codes +- Rate limits + +### Workflow Guide Example +- Prerequisites +- Step-by-step instructions +- Common patterns +- Troubleshooting +- Best practices +""" + +EXAMPLE_ASSET = """# Example Asset File + +This placeholder represents where asset files would be stored. +Replace with actual asset files (templates, images, fonts, etc.) or delete if not needed. + +Asset files are NOT intended to be loaded into context, but rather used within +the output Codex produces. + +Example asset files from other skills: +- Brand guidelines: logo.png, slides_template.pptx +- Frontend builder: hello-world/ directory with HTML/React boilerplate +- Typography: custom-font.ttf, font-family.woff2 +- Data: sample_data.csv, test_dataset.json + +## Common Asset Types + +- Templates: .pptx, .docx, boilerplate directories +- Images: .png, .jpg, .svg, .gif +- Fonts: .ttf, .otf, .woff, .woff2 +- Boilerplate code: Project directories, starter files +- Icons: .ico, .svg +- Data files: .csv, .json, .xml, .yaml + +Note: This is a text placeholder. Actual assets can be any file type. +""" + + +def normalize_skill_name(skill_name): + """Normalize a skill name to lowercase hyphen-case.""" + normalized = skill_name.strip().lower() + normalized = re.sub(r"[^a-z0-9]+", "-", normalized) + normalized = normalized.strip("-") + normalized = re.sub(r"-{2,}", "-", normalized) + return normalized + + +def title_case_skill_name(skill_name): + """Convert hyphenated skill name to Title Case for display.""" + return " ".join(word.capitalize() for word in skill_name.split("-")) + + +def parse_resources(raw_resources): + if not raw_resources: + return [] + resources = [item.strip() for item in raw_resources.split(",") if item.strip()] + invalid = sorted({item for item in resources if item not in ALLOWED_RESOURCES}) + if invalid: + allowed = ", ".join(sorted(ALLOWED_RESOURCES)) + print(f"[ERROR] Unknown resource type(s): {', '.join(invalid)}") + print(f" Allowed: {allowed}") + sys.exit(1) + deduped = [] + seen = set() + for resource in resources: + if resource not in seen: + deduped.append(resource) + seen.add(resource) + return deduped + + +def create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples): + for resource in resources: + resource_dir = skill_dir / resource + resource_dir.mkdir(exist_ok=True) + if resource == "scripts": + if include_examples: + example_script = resource_dir / "example.py" + example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) + example_script.chmod(0o755) + print("[OK] Created scripts/example.py") + else: + print("[OK] Created scripts/") + elif resource == "references": + if include_examples: + example_reference = resource_dir / "api_reference.md" + example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) + print("[OK] Created references/api_reference.md") + else: + print("[OK] Created references/") + elif resource == "assets": + if include_examples: + example_asset = resource_dir / "example_asset.txt" + example_asset.write_text(EXAMPLE_ASSET) + print("[OK] Created assets/example_asset.txt") + else: + print("[OK] Created assets/") + + +def init_skill(skill_name, path, resources, include_examples): + """ + Initialize a new skill directory with template SKILL.md. + + Args: + skill_name: Name of the skill + path: Path where the skill directory should be created + resources: Resource directories to create + include_examples: Whether to create example files in resource directories + + Returns: + Path to created skill directory, or None if error + """ + # Determine skill directory path + skill_dir = Path(path).resolve() / skill_name + + # Check if directory already exists + if skill_dir.exists(): + print(f"[ERROR] Skill directory already exists: {skill_dir}") + return None + + # Create skill directory + try: + skill_dir.mkdir(parents=True, exist_ok=False) + print(f"[OK] Created skill directory: {skill_dir}") + except Exception as e: + print(f"[ERROR] Error creating directory: {e}") + return None + + # Create SKILL.md from template + skill_title = title_case_skill_name(skill_name) + skill_content = SKILL_TEMPLATE.format(skill_name=skill_name, skill_title=skill_title) + + skill_md_path = skill_dir / "SKILL.md" + try: + skill_md_path.write_text(skill_content) + print("[OK] Created SKILL.md") + except Exception as e: + print(f"[ERROR] Error creating SKILL.md: {e}") + return None + + # Create resource directories if requested + if resources: + try: + create_resource_dirs(skill_dir, skill_name, skill_title, resources, include_examples) + except Exception as e: + print(f"[ERROR] Error creating resource directories: {e}") + return None + + # Print next steps + print(f"\n[OK] Skill '{skill_name}' initialized successfully at {skill_dir}") + print("\nNext steps:") + print("1. Edit SKILL.md to complete the TODO items and update the description") + if resources: + if include_examples: + print("2. Customize or delete the example files in scripts/, references/, and assets/") + else: + print("2. Add resources to scripts/, references/, and assets/ as needed") + else: + print("2. Create resource directories only if needed (scripts/, references/, assets/)") + print("3. Run the validator when ready to check the skill structure") + + return skill_dir + + +def main(): + parser = argparse.ArgumentParser( + description="Create a new skill directory with a SKILL.md template.", + ) + parser.add_argument("skill_name", help="Skill name (normalized to hyphen-case)") + parser.add_argument("--path", required=True, help="Output directory for the skill") + parser.add_argument( + "--resources", + default="", + help="Comma-separated list: scripts,references,assets", + ) + parser.add_argument( + "--examples", + action="store_true", + help="Create example files inside the selected resource directories", + ) + args = parser.parse_args() + + raw_skill_name = args.skill_name + skill_name = normalize_skill_name(raw_skill_name) + if not skill_name: + print("[ERROR] Skill name must include at least one letter or digit.") + sys.exit(1) + if len(skill_name) > MAX_SKILL_NAME_LENGTH: + print( + f"[ERROR] Skill name '{skill_name}' is too long ({len(skill_name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + sys.exit(1) + if skill_name != raw_skill_name: + print(f"Note: Normalized skill name from '{raw_skill_name}' to '{skill_name}'.") + + resources = parse_resources(args.resources) + if args.examples and not resources: + print("[ERROR] --examples requires --resources to be set.") + sys.exit(1) + + path = args.path + + print(f"Initializing skill: {skill_name}") + print(f" Location: {path}") + if resources: + print(f" Resources: {', '.join(resources)}") + if args.examples: + print(" Examples: enabled") + else: + print(" Resources: none (create as needed)") + print() + + result = init_skill(skill_name, path, resources, args.examples) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py new file mode 100755 index 0000000..aa4de89 --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/package_skill.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +""" +Skill Packager - Creates a distributable .skill file of a skill folder + +Usage: + python utils/package_skill.py [output-directory] + +Example: + python utils/package_skill.py skills/public/my-skill + python utils/package_skill.py skills/public/my-skill ./dist +""" + +import sys +import zipfile +from pathlib import Path + +from quick_validate import validate_skill + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def package_skill(skill_path, output_dir=None): + """ + Package a skill folder into a .skill file. + + Args: + skill_path: Path to the skill folder + output_dir: Optional output directory for the .skill file (defaults to current directory) + + Returns: + Path to the created .skill file, or None if error + """ + skill_path = Path(skill_path).resolve() + + # Validate skill folder exists + if not skill_path.exists(): + print(f"[ERROR] Skill folder not found: {skill_path}") + return None + + if not skill_path.is_dir(): + print(f"[ERROR] Path is not a directory: {skill_path}") + return None + + # Validate SKILL.md exists + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + print(f"[ERROR] SKILL.md not found in {skill_path}") + return None + + # Run validation before packaging + print("Validating skill...") + valid, message = validate_skill(skill_path) + if not valid: + print(f"[ERROR] Validation failed: {message}") + print(" Please fix the validation errors before packaging.") + return None + print(f"[OK] {message}\n") + + # Determine output location + skill_name = skill_path.name + if output_dir: + output_path = Path(output_dir).resolve() + output_path.mkdir(parents=True, exist_ok=True) + else: + output_path = Path.cwd() + + skill_filename = output_path / f"{skill_name}.skill" + + EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + + # Create the .skill file (zip format) + try: + with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: + # Walk through the skill directory + for file_path in skill_path.rglob("*"): + # Security: never follow or package symlinks. + if file_path.is_symlink(): + print(f"[WARN] Skipping symlink: {file_path}") + continue + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == skill_filename.resolve(): + print(f"[WARN] Skipping output archive: {file_path}") + continue + + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") + + print(f"\n[OK] Successfully packaged skill to: {skill_filename}") + return skill_filename + + except Exception as e: + print(f"[ERROR] Error creating .skill file: {e}") + return None + + +def main(): + if len(sys.argv) < 2: + print("Usage: python utils/package_skill.py [output-directory]") + print("\nExample:") + print(" python utils/package_skill.py skills/public/my-skill") + print(" python utils/package_skill.py skills/public/my-skill ./dist") + sys.exit(1) + + skill_path = sys.argv[1] + output_dir = sys.argv[2] if len(sys.argv) > 2 else None + + print(f"Packaging skill: {skill_path}") + if output_dir: + print(f" Output directory: {output_dir}") + print() + + result = package_skill(skill_path, output_dir) + + if result: + sys.exit(0) + else: + sys.exit(1) + + +if __name__ == "__main__": + main() From 01744029d855a7a2d4648c16a0ecd9073511c8e6 Mon Sep 17 00:00:00 2001 From: astvacp Date: Tue, 3 Mar 2026 18:08:50 +0700 Subject: [PATCH 014/113] fix problem with proxy for Telegram This PR fixes problem with proxy for Telegram --- nanobot/channels/telegram.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..dd05315 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -140,10 +140,14 @@ class TelegramChannel(BaseChannel): self._running = True # Build the application with larger connection pool to avoid pool-timeout on long runs - req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) + req = HTTPXRequest( + connection_pool_size=16, + pool_timeout=5.0, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.config.proxy if self.config.proxy else None + ) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) - if self.config.proxy: - builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) From 0f1cc40b22997fd712c719a6dbc3a970491360e2 Mon Sep 17 00:00:00 2001 From: WufeiHalf Date: Tue, 3 Mar 2026 22:08:01 +0800 Subject: [PATCH 015/113] feat(telegram): add Telegram group topic support --- nanobot/channels/telegram.py | 66 +++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..344ed0c 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -232,6 +232,10 @@ class TelegramChannel(BaseChannel): except ValueError: logger.error("Invalid chat_id: {}", msg.chat_id) return + message_thread_id = msg.metadata.get("message_thread_id") + thread_kwargs = {} + if message_thread_id is not None: + thread_kwargs["message_thread_id"] = message_thread_id reply_params = None if self.config.reply_to_message: @@ -256,7 +260,8 @@ class TelegramChannel(BaseChannel): await sender( chat_id=chat_id, **{param: f}, - reply_parameters=reply_params + reply_parameters=reply_params, + **thread_kwargs, ) except Exception as e: filename = media_path.rsplit("/", 1)[-1] @@ -264,7 +269,8 @@ class TelegramChannel(BaseChannel): await self._app.bot.send_message( chat_id=chat_id, text=f"[Failed to send: {filename}]", - reply_parameters=reply_params + reply_parameters=reply_params, + **thread_kwargs, ) # Send text content @@ -276,7 +282,8 @@ class TelegramChannel(BaseChannel): chat_id=chat_id, text=html, parse_mode="HTML", - reply_parameters=reply_params + reply_parameters=reply_params, + **thread_kwargs, ) except Exception as e: logger.warning("HTML parse failed, falling back to plain text: {}", e) @@ -284,7 +291,8 @@ class TelegramChannel(BaseChannel): await self._app.bot.send_message( chat_id=chat_id, text=chunk, - reply_parameters=reply_params + reply_parameters=reply_params, + **thread_kwargs, ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) @@ -318,14 +326,39 @@ class TelegramChannel(BaseChannel): sid = str(user.id) return f"{sid}|{user.username}" if user.username else sid + @staticmethod + def _derive_topic_session_key(message) -> str | None: + """Derive topic-scoped session key for non-private Telegram chats.""" + message_thread_id = getattr(message, "message_thread_id", None) + if message.chat.type == "private" or message_thread_id is None: + return None + return f"telegram:{message.chat_id}:topic:{message_thread_id}" + + @staticmethod + def _build_message_metadata(message, user) -> dict: + """Build common Telegram inbound metadata payload.""" + return { + "message_id": message.message_id, + "user_id": user.id, + "username": user.username, + "first_name": user.first_name, + "is_group": message.chat.type != "private", + "message_thread_id": getattr(message, "message_thread_id", None), + "is_forum": bool(getattr(message.chat, "is_forum", False)), + } + async def _forward_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Forward slash commands to the bus for unified handling in AgentLoop.""" if not update.message or not update.effective_user: return + message = update.message + user = update.effective_user await self._handle_message( - sender_id=self._sender_id(update.effective_user), - chat_id=str(update.message.chat_id), - content=update.message.text, + sender_id=self._sender_id(user), + chat_id=str(message.chat_id), + content=message.text, + metadata=self._build_message_metadata(message, user), + session_key=self._derive_topic_session_key(message), ) async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -407,6 +440,8 @@ class TelegramChannel(BaseChannel): logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) str_chat_id = str(chat_id) + metadata = self._build_message_metadata(message, user) + session_key = self._derive_topic_session_key(message) # Telegram media groups: buffer briefly, forward as one aggregated turn. if media_group_id := getattr(message, "media_group_id", None): @@ -415,11 +450,8 @@ class TelegramChannel(BaseChannel): self._media_group_buffers[key] = { "sender_id": sender_id, "chat_id": str_chat_id, "contents": [], "media": [], - "metadata": { - "message_id": message.message_id, "user_id": user.id, - "username": user.username, "first_name": user.first_name, - "is_group": message.chat.type != "private", - }, + "metadata": metadata, + "session_key": session_key, } self._start_typing(str_chat_id) buf = self._media_group_buffers[key] @@ -439,13 +471,8 @@ class TelegramChannel(BaseChannel): chat_id=str_chat_id, content=content, media=media_paths, - metadata={ - "message_id": message.message_id, - "user_id": user.id, - "username": user.username, - "first_name": user.first_name, - "is_group": message.chat.type != "private" - } + metadata=metadata, + session_key=session_key, ) async def _flush_media_group(self, key: str) -> None: @@ -459,6 +486,7 @@ class TelegramChannel(BaseChannel): sender_id=buf["sender_id"], chat_id=buf["chat_id"], content=content, media=list(dict.fromkeys(buf["media"])), metadata=buf["metadata"], + session_key=buf.get("session_key"), ) finally: self._media_group_tasks.pop(key, None) From 102b9716ed154782a7d17be720e0a4a888889156 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Tue, 3 Mar 2026 17:16:08 +0100 Subject: [PATCH 016/113] feat: Implement Telegram draft/progress messages (streaming) --- nanobot/channels/telegram.py | 38 ++++++++++++++++++++++++++---------- pyproject.toml | 5 ++++- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..5f739e5 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -269,23 +269,41 @@ class TelegramChannel(BaseChannel): # Send text content if msg.content and msg.content != "[empty message]": + is_progress = msg.metadata.get("_progress", False) + draft_id = msg.metadata.get("message_id") + 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", - reply_parameters=reply_params - ) - except Exception as e: - logger.warning("HTML parse failed, falling back to plain text: {}", e) - try: + if is_progress and draft_id: + await self._app.bot.send_message_draft( + chat_id=chat_id, + draft_id=draft_id, + text=html, + parse_mode="HTML" + ) + else: await self._app.bot.send_message( chat_id=chat_id, - text=chunk, + text=html, + parse_mode="HTML", reply_parameters=reply_params ) + except Exception as e: + logger.warning("HTML parse failed (or draft send failed), falling back to plain text: {}", e) + try: + if is_progress and draft_id: + await self._app.bot.send_message_draft( + chat_id=chat_id, + draft_id=draft_id, + text=chunk + ) + else: + await self._app.bot.send_message( + chat_id=chat_id, + text=chunk, + reply_parameters=reply_params + ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) diff --git a/pyproject.toml b/pyproject.toml index a22053c..42f6194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks]>=22.0,<23.0", + "python-telegram-bot[socks] @ git+https://github.com/python-telegram-bot/python-telegram-bot.git@master", "lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0,<2.0.0", "python-socketio>=5.16.0,<6.0.0", @@ -63,6 +63,9 @@ nanobot = "nanobot.cli.commands:app" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.build.targets.wheel] packages = ["nanobot"] From 3e83425142334c6d712c210ac73254488f749150 Mon Sep 17 00:00:00 2001 From: worenidewen Date: Wed, 4 Mar 2026 01:06:04 +0800 Subject: [PATCH 017/113] feat(mcp): add SSE transport support with auto-detection --- nanobot/agent/tools/mcp.py | 37 ++++++++++++++++++-- nanobot/config/schema.py | 72 ++++++++++++++++++++++++++------------ 2 files changed, 83 insertions(+), 26 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 37464e1..151aa55 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -62,12 +62,43 @@ async def connect_mcp_servers( for name, cfg in mcp_servers.items(): try: - if cfg.command: + transport_type = cfg.type + if not transport_type: + if cfg.command: + transport_type = "stdio" + elif cfg.url: + transport_type = ( + "sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp" + ) + else: + logger.warning("MCP server '{}': no command or url configured, skipping", name) + continue + + if transport_type == "stdio": params = StdioServerParameters( command=cfg.command, args=cfg.args, env=cfg.env or None ) read, write = await stack.enter_async_context(stdio_client(params)) - elif cfg.url: + elif transport_type == "sse": + from mcp.client.sse import sse_client + + def httpx_client_factory( + headers: dict[str, str] | None = None, + timeout: httpx.Timeout | None = None, + auth: httpx.Auth | None = None, + ) -> httpx.AsyncClient: + merged_headers = {**(cfg.headers or {}), **(headers or {})} + return httpx.AsyncClient( + headers=merged_headers or None, + follow_redirects=True, + timeout=timeout, + auth=auth, + ) + + read, write = await stack.enter_async_context( + sse_client(cfg.url, httpx_client_factory=httpx_client_factory) + ) + elif transport_type == "streamableHttp": from mcp.client.streamable_http import streamable_http_client # Always provide an explicit httpx client so MCP HTTP transport does not # inherit httpx's default 5s timeout and preempt the higher-level tool timeout. @@ -82,7 +113,7 @@ async def connect_mcp_servers( streamable_http_client(cfg.url, http_client=http_client) ) else: - logger.warning("MCP server '{}': no command or url configured, skipping", name) + logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type) continue session = await stack.enter_async_context(ClientSession(read, write)) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61a7bd2..64e60dc 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -29,7 +29,9 @@ 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" + ) reply_to_message: bool = False # If true, bot replies quote the original message @@ -42,7 +44,9 @@ class FeishuConfig(Base): encrypt_key: str = "" # Encrypt Key for event subscription (optional) verification_token: str = "" # Verification Token for event subscription (optional) allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids - react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + react_emoji: str = ( + "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + ) class DingTalkConfig(Base): @@ -72,9 +76,13 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). - sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. - max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound). + e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). + sync_stop_grace_seconds: int = ( + 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + ) + max_media_bytes: int = ( + 20 * 1024 * 1024 + ) # Max attachment size accepted for Matrix media handling (inbound + outbound). allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) @@ -105,7 +113,9 @@ 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 @@ -183,27 +193,32 @@ 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 MatrixConfig(Base): """Matrix (Element) channel configuration.""" + enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org + user_id: str = "" # e.g. @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit + e2ee_enabled: bool = True # end-to-end encryption support + sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout + max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) allow_room_mentions: bool = False + class ChannelsConfig(Base): """Configuration for chat channels.""" - send_progress: bool = True # stream agent's text progress to the channel + send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("โ€ฆ")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) @@ -222,7 +237,9 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" - provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + provider: str = ( + "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + ) max_tokens: int = 8192 temperature: float = 0.1 max_tool_iterations: int = 40 @@ -260,8 +277,12 @@ class ProvidersConfig(Base): moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway - volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway + siliconflow: ProviderConfig = Field( + default_factory=ProviderConfig + ) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway + volcengine: ProviderConfig = Field( + default_factory=ProviderConfig + ) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -291,7 +312,9 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" - 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" + ) search: WebSearchConfig = Field(default_factory=WebSearchConfig) @@ -305,12 +328,13 @@ class ExecToolConfig(Base): 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 - headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers - tool_timeout: int = 30 # Seconds before a tool call is cancelled + type: Literal["stdio", "sse", "streamableHttp"] | None = None + command: str = "" + args: list[str] = Field(default_factory=list) + env: dict[str, str] = Field(default_factory=dict) + url: str = "" + headers: dict[str, str] = Field(default_factory=dict) + tool_timeout: int = 30 class ToolsConfig(Base): @@ -336,7 +360,9 @@ 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 From d0a48ed23c7eb578702f9dd5e7d4dc009d022efa Mon Sep 17 00:00:00 2001 From: Liwx Date: Wed, 4 Mar 2026 14:00:40 +0800 Subject: [PATCH 018/113] Update qq.py --- nanobot/channels/qq.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 7b171bc..99a712b 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,6 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) + self._msg_seq: int = 1 # Message sequence to avoid QQ API deduplication async def start(self) -> None: """Start the QQ bot.""" From 20bec3bc266ef84399d3170cef6b4b5de8627f67 Mon Sep 17 00:00:00 2001 From: Liwx Date: Wed, 4 Mar 2026 14:06:19 +0800 Subject: [PATCH 019/113] Update qq.py --- nanobot/channels/qq.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 99a712b..6c58049 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,7 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) - self._msg_seq: int = 1 # Message sequence to avoid QQ API deduplication + self._msg_seq: int = 1 # ๆถˆๆฏๅบๅˆ—ๅท๏ผŒ้ฟๅ…่ขซ QQ API ๅŽป้‡ async def start(self) -> None: """Start the QQ bot.""" @@ -103,11 +103,13 @@ class QQChannel(BaseChannel): return try: msg_id = msg.metadata.get("message_id") + self._msg_seq += 1 # ้€’ๅขžๅบๅˆ—ๅท await self._client.api.post_c2c_message( openid=msg.chat_id, msg_type=0, content=msg.content, msg_id=msg_id, + msg_seq=self._msg_seq, # ๆทปๅŠ ๅบๅˆ—ๅท้ฟๅ…ๅŽป้‡ ) except Exception as e: logger.error("Error sending QQ message: {}", e) @@ -134,3 +136,4 @@ class QQChannel(BaseChannel): ) except Exception: logger.exception("Error handling QQ message") + From df8d09f2b6c0eb23298e41acbe139fad9d38f325 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 10:53:30 +0300 Subject: [PATCH 020/113] fix: guard validate_params against non-dict input When the LLM returns malformed tool arguments (e.g. a list or string instead of a dict), validate_params would crash with AttributeError in _validate() when calling val.items(). Now returns a clear validation error instead of crashing. --- nanobot/agent/tools/base.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c7..051fc9a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") From edaf7a244a0d65395cab954fc768dc8031489b29 Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 10:55:17 +0300 Subject: [PATCH 021/113] fix: handle invalid ISO datetime in CronTool gracefully datetime.fromisoformat(at) raises ValueError for malformed strings, which propagated uncaught and crashed the tool execution. Now catches ValueError and returns a user-friendly error message instead. --- nanobot/agent/tools/cron.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e12..f8e737b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True From ce65f8c11be13b51f242890cabdf15f4e0d1b12a Mon Sep 17 00:00:00 2001 From: Kiplangatkorir Date: Wed, 4 Mar 2026 11:15:45 +0300 Subject: [PATCH 022/113] fix: add size limit to ReadFileTool to prevent OOM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReadFileTool had no file size check โ€” reading a multi-GB file would load everything into memory and crash the process. Now: - Rejects files over ~512KB at the byte level (fast stat check) - Truncates at 128K chars with a notice if content is too long - Guides the agent to use exec with head/tail/grep for large files This matches the protection already in ExecTool (10KB) and WebFetchTool (50KB). --- nanobot/agent/tools/filesystem.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c..7b0b867 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB โ€” prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars โ‰ค 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated โ€” file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" From 61f658e04519ea7e711e6be707765bfd8ee9257d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 12:11:18 +0100 Subject: [PATCH 023/113] add reasoning content to on progress message --- nanobot/agent/loop.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 65a62e5..5eea6e6 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -202,9 +202,16 @@ class AgentLoop: if response.has_tool_calls: if on_progress: - clean = self._strip_think(response.content) - if clean: - await on_progress(clean) + thoughts = [ + self._strip_think(response.content), + response.reasoning_content, + *(f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" + for b in (response.thinking_blocks or []) if isinstance(b, dict) and "signature" in b) + ] + + if combined := "\n\n".join(filter(None, thoughts)): + await on_progress(combined) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ From ca1f41562c11aadb1e9db9bdaace83cd684db31d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 13:19:35 +0100 Subject: [PATCH 024/113] Fix telegram stop typing if not final message --- nanobot/channels/telegram.py | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 5f739e5..de95a15 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -225,7 +225,9 @@ class TelegramChannel(BaseChannel): logger.warning("Telegram bot not running") return - self._stop_typing(msg.chat_id) + # Only stop typing indicator for final responses + if not msg.metadata.get("_progress", False): + self._stop_typing(msg.chat_id) try: chat_id = int(msg.chat_id) diff --git a/pyproject.toml b/pyproject.toml index 42f6194..7ffe8f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks] @ git+https://github.com/python-telegram-bot/python-telegram-bot.git@master", + "python-telegram-bot[socks]>=22.0,<23.0", "lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0,<2.0.0", "python-socketio>=5.16.0,<6.0.0", From bb8512ca842fc3b14c6dee01c5aaf9e241f8344e Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 025/113] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 19 +++++++++---------- tests/test_cron_commands.py | 29 ----------------------------- 2 files changed, 9 insertions(+), 39 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7d..ce796e2 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef5..0000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() From ecdf30940459a27311855a97cfdb7599cb3f89a2 Mon Sep 17 00:00:00 2001 From: Daniel Emden Date: Wed, 4 Mar 2026 15:31:56 +0100 Subject: [PATCH 026/113] fix(codex): pass reasoning_effort to Codex API The OpenAI Codex provider accepts reasoning_effort but silently discards it. Wire it through as {"reasoning": {"effort": ...}} in the request body so the config option actually takes effect. --- nanobot/providers/openai_codex_provider.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa65..d04e210 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) From c64fe0afd8cfcbfe0c26569140db33b473f87854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 16:53:07 +0100 Subject: [PATCH 027/113] fix(tests): resolve failing tests on main branch - Unskip matrix logic by adding missing deps (matrix-nio, nh3, mistune) - Update matrix tests for 'allow_from' default deny security change - Fix asyncio typing keepalive leak in matrix tests - Update context prompt cache assert after runtime message merge - Fix flaky cron service test with mtime sleep - Remove obsolete test_cron_commands.py testing deleted CLI commands --- pyproject.toml | 3 +++ tests/test_context_prompt_cache.py | 9 ++++----- tests/test_cron_commands.py | 29 ----------------------------- tests/test_cron_service.py | 2 ++ tests/test_matrix_channel.py | 20 ++++++++++++++++++-- 5 files changed, 27 insertions(+), 36 deletions(-) delete mode 100644 tests/test_cron_commands.py diff --git a/pyproject.toml b/pyproject.toml index a22053c..0546523 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.scripts] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7d..38b8d35 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] + assert len(messages) == 2 + assert messages[-1]["role"] == "user" + runtime_content = messages[-1]["content"] assert isinstance(runtime_content, str) assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content assert "Current Time:" in runtime_content assert "Channel: cli" in runtime_content assert "Chat ID: direct" in runtime_content - - assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + assert "Return exactly: OK" in runtime_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef5..0000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 2a36f4c..9631da5 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -48,6 +48,8 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: + # Wait slightly to ensure file mtime is definitively different + await asyncio.sleep(0.05) external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c6714c2..c25b95a 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,7 @@ class _FakeAsyncClient: def _make_config(**kwargs) -> MatrixConfig: + kwargs.setdefault("allow_from", ["*"]) return MatrixConfig( enabled=True, homeserver="https://matrix.org", @@ -274,7 +275,7 @@ async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: @pytest.mark.asyncio -async def test_room_invite_joins_when_allow_list_is_empty() -> None: +async def test_room_invite_ignores_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -284,9 +285,22 @@ async def test_room_invite_joins_when_allow_list_is_empty() -> None: await channel._on_room_invite(room, event) - assert client.join_calls == ["!room:matrix.org"] + assert client.join_calls == [] +@pytest.mark.asyncio +async def test_room_invite_joins_when_sender_allowed() -> None: + channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == ["!room:matrix.org"] + @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) @@ -1163,6 +1177,8 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None: assert "!room:matrix.org" in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + await channel.stop() + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: From 88d7642c1ec570e07eef473f47d1d637b38b9b07 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Wed, 4 Mar 2026 20:42:49 +0800 Subject: [PATCH 028/113] test: fix test failures from refactored cron and context builder - test_context_prompt_cache: Update test to reflect merged runtime context and user message (commit ad99d5a merged them into one) - Remove test_cron_commands.py: cron add CLI command was removed in commit c05cb2e (unified scheduling via cron tool) --- tests/test_context_prompt_cache.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 38b8d35..fa7f02d 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -55,11 +55,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - runtime_content = messages[-1]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - assert "Return exactly: OK" in runtime_content + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content From bdfe7d6449dab772f681b857ad76796c92b63d05 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 5 Mar 2026 00:16:31 +0800 Subject: [PATCH 029/113] fix(feishu): convert audio type to file for API compatibility Feishu's GetMessageResource API only accepts 'image' or 'file' as the type parameter. When downloading voice messages, nanobot was passing 'audio' which caused the API to reject the request with an error. This fix converts 'audio' to 'file' in _download_file_sync method before making the API call, allowing voice messages to be downloaded and transcribed successfully. Fixes voice message download failure in Feishu channel. --- nanobot/channels/feishu.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..a9a32b2 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -530,6 +530,10 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" try: request = ( GetMessageResourceRequest.builder() From 0209ad57d9655d8fea5f5e551a4bb89bd0f1691c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 19:31:39 +0100 Subject: [PATCH 030/113] fix(tests): resolve RequestsDependencyWarning and lark-oapi asyncio/websockets DeprecationWarnings --- nanobot/channels/feishu.py | 32 +++++++++++--------------------- pyproject.toml | 1 + 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..7d26fa8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -16,26 +16,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import FeishuConfig -try: - import lark_oapi as lark - from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, - CreateMessageReactionRequest, - CreateMessageReactionRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, - Emoji, - GetMessageResourceRequest, - P2ImMessageReceiveV1, - ) - FEISHU_AVAILABLE = True -except ImportError: - FEISHU_AVAILABLE = False - lark = None - Emoji = None +import importlib.util + +FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None # Message type display mapping MSG_TYPE_MAP = { @@ -280,6 +263,7 @@ class FeishuChannel(BaseChannel): logger.error("Feishu app_id and app_secret not configured") return + import lark_oapi as lark self._running = True self._loop = asyncio.get_running_loop() @@ -340,6 +324,7 @@ class FeishuChannel(BaseChannel): def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" + from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji try: request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ @@ -364,7 +349,7 @@ class FeishuChannel(BaseChannel): Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ - if not self._client or not Emoji: + if not self._client: return loop = asyncio.get_running_loop() @@ -456,6 +441,7 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" + from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -479,6 +465,7 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" + from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -506,6 +493,7 @@ class FeishuChannel(BaseChannel): def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: """Download an image from Feishu message by message_id and image_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = GetMessageResourceRequest.builder() \ .message_id(message_id) \ @@ -530,6 +518,7 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = ( GetMessageResourceRequest.builder() @@ -598,6 +587,7 @@ class FeishuChannel(BaseChannel): def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" + from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody try: request = CreateMessageRequest.builder() \ .receive_id_type(receive_id_type) \ diff --git a/pyproject.toml b/pyproject.toml index 0546523..d384f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "chardet>=3.0.2,<6.0.0", ] [project.optional-dependencies] From e032faaeff81d7e4fa39659badbacc7b4004dc05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Wed, 4 Mar 2026 20:04:00 +0100 Subject: [PATCH 031/113] Merge branch 'main' of upstream/main into fix/test-failures --- .gitignore | 2 +- nanobot/agent/tools/base.py | 2 ++ nanobot/agent/tools/cron.py | 5 ++++- nanobot/agent/tools/filesystem.py | 11 +++++++++++ nanobot/channels/feishu.py | 6 ++++++ nanobot/providers/openai_codex_provider.py | 3 +++ pyproject.toml | 2 ++ tests/test_context_prompt_cache.py | 1 + 8 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d7b930d..742d593 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -tests/ + diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c7..051fc9a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e12..f8e737b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c..7b0b867 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB โ€” prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars โ‰ค 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated โ€” file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 7d26fa8..0cd84c3 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -519,6 +519,12 @@ class FeishuChannel(BaseChannel): ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" from lark_oapi.api.im.v1 import GetMessageResourceRequest + + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" + try: request = ( GetMessageResourceRequest.builder() diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa65..d04e210 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) diff --git a/pyproject.toml b/pyproject.toml index d384f3f..e5214bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,8 @@ dependencies = [ "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", "chardet>=3.0.2,<6.0.0", + "openai>=2.8.0", + ] [project.optional-dependencies] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index fa7f02d..d347e53 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -55,6 +55,7 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert "## Current Session" not in messages[0]["content"] assert len(messages) == 2 + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" user_content = messages[-1]["content"] From f78d655aba78142c21782b7ed93b9d617aa7a65c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E8=BF=9C?= Date: Thu, 5 Mar 2026 04:29:00 +0800 Subject: [PATCH 032/113] Fix: Telegram channel crash when proxy is configured --- nanobot/channels/telegram.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..12b5440 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -140,10 +140,14 @@ class TelegramChannel(BaseChannel): self._running = True # Build the application with larger connection pool to avoid pool-timeout on long runs - req = HTTPXRequest(connection_pool_size=16, pool_timeout=5.0, connect_timeout=30.0, read_timeout=30.0) + req = HTTPXRequest( + connection_pool_size=16, + pool_timeout=5.0, + connect_timeout=30.0, + read_timeout=30.0, + proxy=self.config.proxy if self.config.proxy else None, + ) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) - if self.config.proxy: - builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) From c27d2b15220b2cff00604c4143851b989792fedf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 5 Mar 2026 00:33:27 +0100 Subject: [PATCH 033/113] fix(agent): prevent tool hints from overwriting reasoning in streaming drafts --- nanobot/agent/loop.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 5eea6e6..fc1fd75 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -209,10 +209,13 @@ class AgentLoop: for b in (response.thinking_blocks or []) if isinstance(b, dict) and "signature" in b) ] - if combined := "\n\n".join(filter(None, thoughts)): - await on_progress(combined) - - await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) + combined_thoughts = "\n\n".join(filter(None, thoughts)) + tool_hint_str = self._tool_hint(response.tool_calls) + + if combined_thoughts: + await on_progress(f"{combined_thoughts}\n\n{tool_hint_str}", tool_hint=True) + else: + await on_progress(tool_hint_str, tool_hint=True) tool_call_dicts = [ { From 33f59d8a37a963f5fa694435155f42621d9852ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20S=C3=A1nchez=20Vall=C3=A9s?= Date: Thu, 5 Mar 2026 00:45:15 +0100 Subject: [PATCH 034/113] fix(agent): separate reasoning and tool hints to respect channel config --- nanobot/agent/loop.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index fc1fd75..2f6a2bc 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -213,6 +213,7 @@ class AgentLoop: tool_hint_str = self._tool_hint(response.tool_calls) if combined_thoughts: + await on_progress(combined_thoughts) await on_progress(f"{combined_thoughts}\n\n{tool_hint_str}", tool_hint=True) else: await on_progress(tool_hint_str, tool_hint=True) From a08aae93e6c8ac2b68a4a8d566899b95fd414844 Mon Sep 17 00:00:00 2001 From: hcanyz Date: Thu, 5 Mar 2026 11:33:20 +0800 Subject: [PATCH 035/113] fix: not imported when LiteLLMProvider is not used LiteLLM:WARNING: get_model_cost_map.py:213 - LiteLLM: Failed to fetch remote model cost map from https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json: The read operation timed out. Falling back to local backup. --- nanobot/cli/commands.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index b75a2bc..2597928 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -200,8 +200,6 @@ def onboard(): def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" - from nanobot.providers.custom_provider import CustomProvider - from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider model = config.agents.defaults.model @@ -213,6 +211,7 @@ def _make_provider(config: Config): return OpenAICodexProvider(default_model=model) # Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM + from nanobot.providers.custom_provider import CustomProvider if provider_name == "custom": return CustomProvider( api_key=p.api_key if p else "no-key", @@ -220,6 +219,7 @@ def _make_provider(config: Config): default_model=model, ) + from nanobot.providers.litellm_provider import LiteLLMProvider 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): From 5cc3c032450f5a97c1ebf9bf153974ad00ddc725 Mon Sep 17 00:00:00 2001 From: Peixian Gong Date: Thu, 5 Mar 2026 15:15:37 +0800 Subject: [PATCH 036/113] fix: merge tool_calls from multiple choices in LiteLLM response GitHub Copilot's API returns tool_calls split across multiple choices: - choices[0]: content only (tool_calls=null) - choices[1]: tool_calls only (content=null) The existing _parse_response only inspected choices[0], so tool_calls were silently lost, causing the agent to never execute tools when using github_copilot/ models. This fix scans all choices and merges tool_calls + content, so providers that return multi-choice responses work correctly. Single-choice providers (OpenAI, Anthropic, etc.) are unaffected since the loop over one choice is equivalent to the original code. --- nanobot/providers/litellm_provider.py | 44 +++++++++++++++++++-------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index d8d8ace..a1819a2 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -8,6 +8,7 @@ from typing import Any import json_repair import litellm from litellm import acompletion +from loguru import logger from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.registry import find_by_model, find_gateway @@ -255,20 +256,37 @@ class LiteLLMProvider(LLMProvider): """Parse LiteLLM response into our standard format.""" choice = response.choices[0] message = choice.message + content = message.content + finish_reason = choice.finish_reason + + # Some providers (e.g. GitHub Copilot) split content and tool_calls + # across multiple choices. Merge them so tool_calls are not lost. + raw_tool_calls = [] + for ch in response.choices: + msg = ch.message + if hasattr(msg, "tool_calls") and msg.tool_calls: + raw_tool_calls.extend(msg.tool_calls) + if ch.finish_reason in ("tool_calls", "stop"): + finish_reason = ch.finish_reason + if not content and msg.content: + content = msg.content + + if len(response.choices) > 1: + logger.debug("LiteLLM response has {} choices, merged {} tool_calls", + len(response.choices), len(raw_tool_calls)) tool_calls = [] - if hasattr(message, "tool_calls") and message.tool_calls: - for tc in message.tool_calls: - # Parse arguments from JSON string if needed - args = tc.function.arguments - if isinstance(args, str): - args = json_repair.loads(args) + for tc in raw_tool_calls: + # Parse arguments from JSON string if needed + args = tc.function.arguments + if isinstance(args, str): + args = json_repair.loads(args) - tool_calls.append(ToolCallRequest( - id=_short_tool_id(), - name=tc.function.name, - arguments=args, - )) + tool_calls.append(ToolCallRequest( + id=_short_tool_id(), + name=tc.function.name, + arguments=args, + )) usage = {} if hasattr(response, "usage") and response.usage: @@ -282,9 +300,9 @@ class LiteLLMProvider(LLMProvider): thinking_blocks = getattr(message, "thinking_blocks", None) or None return LLMResponse( - content=message.content, + content=content, tool_calls=tool_calls, - finish_reason=choice.finish_reason or "stop", + finish_reason=finish_reason or "stop", usage=usage, reasoning_content=reasoning_content, thinking_blocks=thinking_blocks, From cf3e7e3f38325224dcb342af448ecd17c11d1d13 Mon Sep 17 00:00:00 2001 From: ouyangwulin Date: Thu, 5 Mar 2026 16:54:15 +0800 Subject: [PATCH 037/113] feat: Add Alibaba Cloud Coding Plan API support Add dashscope_coding_plan provider to registry with OpenAI-compatible endpoint for BaiLian coding assistance. - Supports API key detection by 'sk-sp-' prefix pattern - Adds provider config schema entry for proper loading - Updates documentation with configuration instructions - Fixes duplicate MatrixConfig class issue in schema - Follow existing nanobot provider patterns for consistency --- README.md | 1 + nanobot/config/schema.py | 62 +++++++++++----- nanobot/providers/registry.py | 130 ++++++++++++++++------------------ 3 files changed, 109 insertions(+), 84 deletions(-) diff --git a/README.md b/README.md index 33cdeee..2977ccb 100644 --- a/README.md +++ b/README.md @@ -656,6 +656,7 @@ Config file: `~/.nanobot/config.json` > [!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. +> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian coding assistance), add configuration for `dashscope_coding_plan` provider with an API key starting with `sk-sp-` in your config. This provider uses OpenAI-compatible endpoint `https://coding.dashscope.aliyuncs.com/v1`. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config. diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61a7bd2..538fab8 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -29,7 +29,9 @@ 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" + ) reply_to_message: bool = False # If true, bot replies quote the original message @@ -42,7 +44,9 @@ class FeishuConfig(Base): encrypt_key: str = "" # Encrypt Key for event subscription (optional) verification_token: str = "" # Verification Token for event subscription (optional) allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids - react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + react_emoji: str = ( + "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) + ) class DingTalkConfig(Base): @@ -72,9 +76,13 @@ class MatrixConfig(Base): access_token: str = "" user_id: str = "" # @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). - sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. - max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound). + e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling). + sync_stop_grace_seconds: int = ( + 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + ) + max_media_bytes: int = ( + 20 * 1024 * 1024 + ) # Max attachment size accepted for Matrix media handling (inbound + outbound). allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) @@ -105,7 +113,9 @@ 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 @@ -183,27 +193,32 @@ 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 MatrixConfig(Base): """Matrix (Element) channel configuration.""" + enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org + user_id: str = "" # e.g. @bot:matrix.org device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit + e2ee_enabled: bool = True # end-to-end encryption support + sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout + max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit allow_from: list[str] = Field(default_factory=list) group_policy: Literal["open", "mention", "allowlist"] = "open" group_allow_from: list[str] = Field(default_factory=list) allow_room_mentions: bool = False + class ChannelsConfig(Base): """Configuration for chat channels.""" - send_progress: bool = True # stream agent's text progress to the channel + send_progress: bool = True # stream agent's text progress to the channel send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("โ€ฆ")) whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig) telegram: TelegramConfig = Field(default_factory=TelegramConfig) @@ -222,7 +237,9 @@ class AgentDefaults(Base): workspace: str = "~/.nanobot/workspace" model: str = "anthropic/claude-opus-4-5" - provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + provider: str = ( + "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection + ) max_tokens: int = 8192 temperature: float = 0.1 max_tool_iterations: int = 40 @@ -255,13 +272,20 @@ class ProvidersConfig(Base): groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # ้˜ฟ้‡Œไบ‘้€šไน‰ๅƒ้—ฎ + dashscope_coding_plan: ProviderConfig = Field( + default_factory=ProviderConfig + ) # ้˜ฟ้‡Œไบ‘็™พ็‚ผCoding Plan vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway - volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway + siliconflow: ProviderConfig = Field( + default_factory=ProviderConfig + ) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway + volcengine: ProviderConfig = Field( + default_factory=ProviderConfig + ) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -291,7 +315,9 @@ class WebSearchConfig(Base): class WebToolsConfig(Base): """Web tools configuration.""" - 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" + ) search: WebSearchConfig = Field(default_factory=WebSearchConfig) @@ -336,7 +362,9 @@ 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 diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index df915b7..da04cd7 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -26,33 +26,33 @@ class ProviderSpec: """ # identity - name: str # config field name, e.g. "dashscope" - keywords: tuple[str, ...] # model-name keywords for matching (lowercase) - env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" - display_name: str = "" # shown in `nanobot status` + name: str # config field name, e.g. "dashscope" + keywords: tuple[str, ...] # model-name keywords for matching (lowercase) + env_key: str # LiteLLM env var, e.g. "DASHSCOPE_API_KEY" + display_name: str = "" # shown in `nanobot status` # model prefixing - litellm_prefix: str = "" # "dashscope" โ†’ model becomes "dashscope/{model}" - skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these + litellm_prefix: str = "" # "dashscope" โ†’ model becomes "dashscope/{model}" + skip_prefixes: tuple[str, ...] = () # don't prefix if model already starts with these # extra env vars, e.g. (("ZHIPUAI_API_KEY", "{api_key}"),) env_extras: tuple[tuple[str, str], ...] = () # gateway / local detection - is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) - is_local: bool = False # local deployment (vLLM, Ollama) - detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" - detect_by_base_keyword: str = "" # match substring in api_base URL - default_api_base: str = "" # fallback base URL + is_gateway: bool = False # routes any model (OpenRouter, AiHubMix) + is_local: bool = False # local deployment (vLLM, Ollama) + detect_by_key_prefix: str = "" # match api_key prefix, e.g. "sk-or-" + detect_by_base_keyword: str = "" # match substring in api_base URL + default_api_base: str = "" # fallback base URL # gateway behavior - strip_model_prefix: bool = False # strip "provider/" before re-prefixing + strip_model_prefix: bool = False # strip "provider/" before re-prefixing # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) model_overrides: tuple[tuple[str, dict[str, Any]], ...] = () # OAuth-based providers (e.g., OpenAI Codex) don't use API keys - is_oauth: bool = False # if True, uses OAuth flow instead of API key + is_oauth: bool = False # if True, uses OAuth flow instead of API key # Direct providers bypass LiteLLM entirely (e.g., CustomProvider) is_direct: bool = False @@ -70,7 +70,6 @@ class ProviderSpec: # --------------------------------------------------------------------------- PROVIDERS: tuple[ProviderSpec, ...] = ( - # === Custom (direct OpenAI-compatible endpoint, bypasses LiteLLM) ====== ProviderSpec( name="custom", @@ -80,17 +79,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( litellm_prefix="", is_direct=True, ), - # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. - # OpenRouter: global gateway, keys start with "sk-or-" ProviderSpec( name="openrouter", keywords=("openrouter",), env_key="OPENROUTER_API_KEY", display_name="OpenRouter", - litellm_prefix="openrouter", # claude-3 โ†’ openrouter/claude-3 + litellm_prefix="openrouter", # claude-3 โ†’ openrouter/claude-3 skip_prefixes=(), env_extras=(), is_gateway=True, @@ -102,16 +99,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), supports_prompt_caching=True, ), - # AiHubMix: global gateway, OpenAI-compatible interface. # strip_model_prefix=True: it doesn't understand "anthropic/claude-3", # so we strip to bare "claude-3" then re-prefix as "openai/claude-3". ProviderSpec( name="aihubmix", keywords=("aihubmix",), - env_key="OPENAI_API_KEY", # OpenAI-compatible + env_key="OPENAI_API_KEY", # OpenAI-compatible display_name="AiHubMix", - litellm_prefix="openai", # โ†’ openai/{model} + litellm_prefix="openai", # โ†’ openai/{model} skip_prefixes=(), env_extras=(), is_gateway=True, @@ -119,10 +115,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( detect_by_key_prefix="", detect_by_base_keyword="aihubmix", default_api_base="https://aihubmix.com/v1", - strip_model_prefix=True, # anthropic/claude-3 โ†’ claude-3 โ†’ openai/claude-3 + strip_model_prefix=True, # anthropic/claude-3 โ†’ claude-3 โ†’ openai/claude-3 model_overrides=(), ), - # SiliconFlow (็ก…ๅŸบๆตๅŠจ): OpenAI-compatible gateway, model names keep org prefix ProviderSpec( name="siliconflow", @@ -140,7 +135,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž): OpenAI-compatible gateway ProviderSpec( name="volcengine", @@ -158,9 +152,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Standard providers (matched by model-name keywords) =============== - # Anthropic: LiteLLM recognizes "claude-*" natively, no prefix needed. ProviderSpec( name="anthropic", @@ -179,7 +171,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( model_overrides=(), supports_prompt_caching=True, ), - # OpenAI: LiteLLM recognizes "gpt-*" natively, no prefix needed. ProviderSpec( name="openai", @@ -197,14 +188,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # OpenAI Codex: uses OAuth, not API key. ProviderSpec( name="openai_codex", keywords=("openai-codex",), - env_key="", # OAuth-based, no API key + env_key="", # OAuth-based, no API key display_name="OpenAI Codex", - litellm_prefix="", # Not routed through LiteLLM + litellm_prefix="", # Not routed through LiteLLM skip_prefixes=(), env_extras=(), is_gateway=False, @@ -214,16 +204,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="https://chatgpt.com/backend-api", strip_model_prefix=False, model_overrides=(), - is_oauth=True, # OAuth-based authentication + 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 + env_key="", # OAuth-based, no API key display_name="Github Copilot", - litellm_prefix="github_copilot", # github_copilot/model โ†’ github_copilot/model + litellm_prefix="github_copilot", # github_copilot/model โ†’ github_copilot/model skip_prefixes=("github_copilot/",), env_extras=(), is_gateway=False, @@ -233,17 +222,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( default_api_base="", strip_model_prefix=False, model_overrides=(), - is_oauth=True, # OAuth-based authentication + is_oauth=True, # OAuth-based authentication ), - # DeepSeek: needs "deepseek/" prefix for LiteLLM routing. ProviderSpec( name="deepseek", keywords=("deepseek",), env_key="DEEPSEEK_API_KEY", display_name="DeepSeek", - litellm_prefix="deepseek", # deepseek-chat โ†’ deepseek/deepseek-chat - skip_prefixes=("deepseek/",), # avoid double-prefix + litellm_prefix="deepseek", # deepseek-chat โ†’ deepseek/deepseek-chat + skip_prefixes=("deepseek/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -253,15 +241,14 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Gemini: needs "gemini/" prefix for LiteLLM. ProviderSpec( name="gemini", keywords=("gemini",), env_key="GEMINI_API_KEY", display_name="Gemini", - litellm_prefix="gemini", # gemini-pro โ†’ gemini/gemini-pro - skip_prefixes=("gemini/",), # avoid double-prefix + litellm_prefix="gemini", # gemini-pro โ†’ gemini/gemini-pro + skip_prefixes=("gemini/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -271,7 +258,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Zhipu: LiteLLM uses "zai/" prefix. # Also mirrors key to ZHIPUAI_API_KEY (some LiteLLM paths check that). # skip_prefixes: don't add "zai/" when already routed via gateway. @@ -280,11 +266,9 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("zhipu", "glm", "zai"), env_key="ZAI_API_KEY", display_name="Zhipu AI", - litellm_prefix="zai", # glm-4 โ†’ zai/glm-4 + litellm_prefix="zai", # glm-4 โ†’ zai/glm-4 skip_prefixes=("zhipu/", "zai/", "openrouter/", "hosted_vllm/"), - env_extras=( - ("ZHIPUAI_API_KEY", "{api_key}"), - ), + env_extras=(("ZHIPUAI_API_KEY", "{api_key}"),), is_gateway=False, is_local=False, detect_by_key_prefix="", @@ -293,14 +277,13 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # DashScope: Qwen models, needs "dashscope/" prefix. ProviderSpec( name="dashscope", keywords=("qwen", "dashscope"), env_key="DASHSCOPE_API_KEY", display_name="DashScope", - litellm_prefix="dashscope", # qwen-max โ†’ dashscope/qwen-max + litellm_prefix="dashscope", # qwen-max โ†’ dashscope/qwen-max skip_prefixes=("dashscope/", "openrouter/"), env_extras=(), is_gateway=False, @@ -311,7 +294,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # Moonshot: Kimi models, needs "moonshot/" prefix. # LiteLLM requires MOONSHOT_API_BASE env var to find the endpoint. # Kimi K2.5 API enforces temperature >= 1.0. @@ -320,22 +302,17 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("moonshot", "kimi"), env_key="MOONSHOT_API_KEY", display_name="Moonshot", - litellm_prefix="moonshot", # kimi-k2.5 โ†’ moonshot/kimi-k2.5 + litellm_prefix="moonshot", # kimi-k2.5 โ†’ moonshot/kimi-k2.5 skip_prefixes=("moonshot/", "openrouter/"), - env_extras=( - ("MOONSHOT_API_BASE", "{api_base}"), - ), + env_extras=(("MOONSHOT_API_BASE", "{api_base}"),), is_gateway=False, is_local=False, detect_by_key_prefix="", detect_by_base_keyword="", - default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China + default_api_base="https://api.moonshot.ai/v1", # intl; use api.moonshot.cn for China strip_model_prefix=False, - model_overrides=( - ("kimi-k2.5", {"temperature": 1.0}), - ), + model_overrides=(("kimi-k2.5", {"temperature": 1.0}),), ), - # MiniMax: needs "minimax/" prefix for LiteLLM routing. # Uses OpenAI-compatible API at api.minimax.io/v1. ProviderSpec( @@ -343,7 +320,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("minimax",), env_key="MINIMAX_API_KEY", display_name="MiniMax", - litellm_prefix="minimax", # MiniMax-M2.1 โ†’ minimax/MiniMax-M2.1 + litellm_prefix="minimax", # MiniMax-M2.1 โ†’ minimax/MiniMax-M2.1 skip_prefixes=("minimax/", "openrouter/"), env_extras=(), is_gateway=False, @@ -354,9 +331,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Local deployment (matched by config key, NOT by api_base) ========= - # vLLM / any OpenAI-compatible local server. # Detected when config key is "vllm" (provider_name="vllm"). ProviderSpec( @@ -364,20 +339,38 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("vllm",), env_key="HOSTED_VLLM_API_KEY", display_name="vLLM/Local", - litellm_prefix="hosted_vllm", # Llama-3-8B โ†’ hosted_vllm/Llama-3-8B + litellm_prefix="hosted_vllm", # Llama-3-8B โ†’ hosted_vllm/Llama-3-8B skip_prefixes=(), env_extras=(), is_gateway=False, is_local=True, detect_by_key_prefix="", detect_by_base_keyword="", - default_api_base="", # user must provide in config + default_api_base="", # user must provide in config + strip_model_prefix=False, + model_overrides=(), + ), + # === Coding Plan Gateway Providers ===================================== + # Alibaba Cloud Coding Plan: OpenAI-compatible gateway for coding assistance. + # Uses special API key format starting with "sk-sp-" to distinguish it + # from regular dashscope keys. Uses the OpenAI-compatible endpoint. + ProviderSpec( + name="dashscope_coding_plan", + keywords=("dashscope-coding-plan", "coding-plan", "aliyun-coding", "bailian-coding"), + env_key="DASHSCOPE_CODING_PLAN_API_KEY", + display_name="Alibaba Cloud Coding Plan", + litellm_prefix="dashscope", # โ†’ dashscope/{model} + skip_prefixes=("dashscope/", "openrouter/"), + env_extras=(), + is_gateway=True, + is_local=False, + detect_by_key_prefix="sk-sp-", # coding plan API keys start with "sk-sp-" + detect_by_base_keyword="coding.dashscope", + default_api_base="https://coding.dashscope.aliyuncs.com/v1", strip_model_prefix=False, model_overrides=(), ), - # === Auxiliary (not a primary LLM provider) ============================ - # Groq: mainly used for Whisper voice transcription, also usable for LLM. # Needs "groq/" prefix for LiteLLM routing. Placed last โ€” it rarely wins fallback. ProviderSpec( @@ -385,8 +378,8 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("groq",), env_key="GROQ_API_KEY", display_name="Groq", - litellm_prefix="groq", # llama3-8b-8192 โ†’ groq/llama3-8b-8192 - skip_prefixes=("groq/",), # avoid double-prefix + litellm_prefix="groq", # llama3-8b-8192 โ†’ groq/llama3-8b-8192 + skip_prefixes=("groq/",), # avoid double-prefix env_extras=(), is_gateway=False, is_local=False, @@ -403,6 +396,7 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( # Lookup helpers # --------------------------------------------------------------------------- + def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local โ€” those are matched by api_key/api_base instead.""" @@ -418,7 +412,9 @@ def find_by_model(model: str) -> ProviderSpec | None: return spec for spec in std_specs: - if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords + ): return spec return None From 9e42ccb51e30817b3720707c9114e13278081206 Mon Sep 17 00:00:00 2001 From: Barry Wang Date: Thu, 5 Mar 2026 16:27:50 +0800 Subject: [PATCH 038/113] feat: auto casting tool params to match schema type --- nanobot/agent/tools/base.py | 114 ++++++++++++++++++++++++++++++ nanobot/agent/tools/registry.py | 4 ++ tests/test_tool_validation.py | 119 ++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 051fc9a..b09cfb8 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -3,6 +3,8 @@ from abc import ABC, abstractmethod from typing import Any +from loguru import logger + class Tool(ABC): """ @@ -52,6 +54,118 @@ class Tool(ABC): """ pass + def cast_params(self, params: dict[str, Any]) -> dict[str, Any]: + """ + Attempt to cast parameters to match schema types. + Returns modified params dict. Raises ValueError if casting is impossible. + """ + schema = self.parameters or {} + if schema.get("type", "object") != "object": + return params + + return self._cast_object(params, schema) + + def _cast_object(self, obj: Any, schema: dict[str, Any]) -> dict[str, Any]: + """Cast an object (dict) according to schema.""" + if not isinstance(obj, dict): + return obj + + props = schema.get("properties", {}) + result = {} + + for key, value in obj.items(): + if key in props: + result[key] = self._cast_value(value, props[key]) + else: + result[key] = value + + return result + + def _cast_value(self, val: Any, schema: dict[str, Any]) -> Any: + """Cast a single value according to schema.""" + target_type = schema.get("type") + + # Already correct type + # Note: check bool before int since bool is subclass of int + if target_type == "boolean" and isinstance(val, bool): + return val + if target_type == "integer" and isinstance(val, int) and not isinstance(val, bool): + return val + # For array/object, don't early-return - we need to recurse into contents + if target_type in self._TYPE_MAP and target_type not in ( + "boolean", + "integer", + "array", + "object", + ): + expected = self._TYPE_MAP[target_type] + if isinstance(val, expected): + return val + + # Attempt casting + try: + if target_type == "integer": + if isinstance(val, bool): + # Don't silently convert bool to int + raise ValueError(f"Cannot cast bool to integer") + if isinstance(val, str): + return int(val) + if isinstance(val, (int, float)): + return int(val) + + elif target_type == "number": + if isinstance(val, bool): + # Don't silently convert bool to number + raise ValueError(f"Cannot cast bool to number") + if isinstance(val, str): + return float(val) + if isinstance(val, (int, float)): + return float(val) + + elif target_type == "string": + # Preserve None vs empty string distinction + if val is None: + return val + return str(val) + + elif target_type == "boolean": + if isinstance(val, str): + return val.lower() in ("true", "1", "yes") + return bool(val) + + elif target_type == "array": + if isinstance(val, list): + # Recursively cast array items if schema defines items + if "items" in schema: + return [self._cast_value(item, schema["items"]) for item in val] + return val + # Preserve None vs empty array distinction + if val is None: + return val + # Try to convert single value to array + if val == "": + return [] + return [val] + + elif target_type == "object": + if isinstance(val, dict): + return self._cast_object(val, schema) + # Preserve None vs empty object distinction + if val is None: + return val + # Empty string โ†’ empty object + if val == "": + return {} + # Cannot cast to object + raise ValueError(f"Cannot cast {type(val).__name__} to object") + + except (ValueError, TypeError) as e: + # Log failed casts for debugging, return original value + # Let validation catch the error + logger.debug("Failed to cast value %r to %s: %s", val, target_type, e) + + return val + def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" if not isinstance(params, dict): diff --git a/nanobot/agent/tools/registry.py b/nanobot/agent/tools/registry.py index 5d36e52..896491f 100644 --- a/nanobot/agent/tools/registry.py +++ b/nanobot/agent/tools/registry.py @@ -44,6 +44,10 @@ class ToolRegistry: return f"Error: Tool '{name}' not found. Available: {', '.join(self.tool_names)}" try: + # Attempt to cast parameters to match schema types + params = tool.cast_params(params) + + # Validate parameters errors = tool.validate_params(params) if errors: return f"Error: Invalid parameters for tool '{name}': " + "; ".join(errors) + _HINT diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py index cb50fb0..0083b4e 100644 --- a/tests/test_tool_validation.py +++ b/tests/test_tool_validation.py @@ -106,3 +106,122 @@ def test_exec_extract_absolute_paths_captures_posix_absolute_paths() -> None: paths = ExecTool._extract_absolute_paths(cmd) assert "/tmp/data.txt" in paths assert "/tmp/out.txt" in paths + + +# --- cast_params tests --- + + +class CastTestTool(Tool): + """Minimal tool for testing cast_params.""" + + def __init__(self, schema: dict[str, Any]) -> None: + self._schema = schema + + @property + def name(self) -> str: + return "cast_test" + + @property + def description(self) -> str: + return "test tool for casting" + + @property + def parameters(self) -> dict[str, Any]: + return self._schema + + async def execute(self, **kwargs: Any) -> str: + return "ok" + + +def test_cast_params_string_to_int() -> None: + tool = CastTestTool( + { + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + ) + result = tool.cast_params({"count": "42"}) + assert result["count"] == 42 + assert isinstance(result["count"], int) + + +def test_cast_params_string_to_number() -> None: + tool = CastTestTool( + { + "type": "object", + "properties": {"rate": {"type": "number"}}, + } + ) + result = tool.cast_params({"rate": "3.14"}) + assert result["rate"] == 3.14 + assert isinstance(result["rate"], float) + + +def test_cast_params_string_to_bool() -> None: + tool = CastTestTool( + { + "type": "object", + "properties": {"enabled": {"type": "boolean"}}, + } + ) + assert tool.cast_params({"enabled": "true"})["enabled"] is True + assert tool.cast_params({"enabled": "false"})["enabled"] is False + assert tool.cast_params({"enabled": "1"})["enabled"] is True + + +def test_cast_params_array_items() -> None: + tool = CastTestTool( + { + "type": "object", + "properties": { + "nums": {"type": "array", "items": {"type": "integer"}}, + }, + } + ) + result = tool.cast_params({"nums": ["1", "2", "3"]}) + assert result["nums"] == [1, 2, 3] + + +def test_cast_params_nested_object() -> None: + tool = CastTestTool( + { + "type": "object", + "properties": { + "config": { + "type": "object", + "properties": { + "port": {"type": "integer"}, + "debug": {"type": "boolean"}, + }, + }, + }, + } + ) + result = tool.cast_params({"config": {"port": "8080", "debug": "true"}}) + assert result["config"]["port"] == 8080 + assert result["config"]["debug"] is True + + +def test_cast_params_bool_not_cast_to_int() -> None: + """Booleans should not be silently cast to integers.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + ) + # Bool input should remain bool (validation will catch it) + result = tool.cast_params({"count": True}) + assert result["count"] is True # Not cast to 1 + + +def test_cast_params_preserves_empty_string() -> None: + """Empty strings should be preserved for string type.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"name": {"type": "string"}}, + } + ) + result = tool.cast_params({"name": ""}) + assert result["name"] == "" From 667613d5941d70243baedd22d43852f7ec27801e Mon Sep 17 00:00:00 2001 From: Barry Wang Date: Thu, 5 Mar 2026 16:51:09 +0800 Subject: [PATCH 039/113] fix edge case casting and more test cases --- nanobot/agent/tools/base.py | 20 +++-- tests/test_tool_validation.py | 136 ++++++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 6 deletions(-) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index b09cfb8..fb34fe8 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -57,7 +57,8 @@ class Tool(ABC): def cast_params(self, params: dict[str, Any]) -> dict[str, Any]: """ Attempt to cast parameters to match schema types. - Returns modified params dict. Raises ValueError if casting is impossible. + Returns modified params dict. If casting fails, returns original value + and logs a debug message, allowing validation to catch the error. """ schema = self.parameters or {} if schema.get("type", "object") != "object": @@ -107,7 +108,7 @@ class Tool(ABC): if target_type == "integer": if isinstance(val, bool): # Don't silently convert bool to int - raise ValueError(f"Cannot cast bool to integer") + raise ValueError("Cannot cast bool to integer") if isinstance(val, str): return int(val) if isinstance(val, (int, float)): @@ -116,7 +117,7 @@ class Tool(ABC): elif target_type == "number": if isinstance(val, bool): # Don't silently convert bool to number - raise ValueError(f"Cannot cast bool to number") + raise ValueError("Cannot cast bool to number") if isinstance(val, str): return float(val) if isinstance(val, (int, float)): @@ -130,7 +131,13 @@ class Tool(ABC): elif target_type == "boolean": if isinstance(val, str): - return val.lower() in ("true", "1", "yes") + val_lower = val.lower() + if val_lower in ("true", "1", "yes"): + return True + elif val_lower in ("false", "0", "no"): + return False + # For other strings, raise error to let validation handle it + raise ValueError(f"Cannot convert string '{val}' to boolean") return bool(val) elif target_type == "array": @@ -142,10 +149,11 @@ class Tool(ABC): # Preserve None vs empty array distinction if val is None: return val - # Try to convert single value to array + # Empty string โ†’ empty array if val == "": return [] - return [val] + # Don't auto-wrap single values, let validation catch the error + raise ValueError(f"Cannot convert {type(val).__name__} to array") elif target_type == "object": if isinstance(val, dict): diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py index 0083b4e..6fb87ea 100644 --- a/tests/test_tool_validation.py +++ b/tests/test_tool_validation.py @@ -225,3 +225,139 @@ def test_cast_params_preserves_empty_string() -> None: ) result = tool.cast_params({"name": ""}) assert result["name"] == "" + + +def test_cast_params_bool_string_false() -> None: + """Test that 'false', '0', 'no' strings convert to False.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"flag": {"type": "boolean"}}, + } + ) + assert tool.cast_params({"flag": "false"})["flag"] is False + assert tool.cast_params({"flag": "False"})["flag"] is False + assert tool.cast_params({"flag": "0"})["flag"] is False + assert tool.cast_params({"flag": "no"})["flag"] is False + assert tool.cast_params({"flag": "NO"})["flag"] is False + + +def test_cast_params_bool_string_invalid() -> None: + """Invalid boolean strings should not be cast.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"flag": {"type": "boolean"}}, + } + ) + # Invalid strings should be preserved (validation will catch them) + result = tool.cast_params({"flag": "random"}) + assert result["flag"] == "random" + result = tool.cast_params({"flag": "maybe"}) + assert result["flag"] == "maybe" + + +def test_cast_params_invalid_string_to_int() -> None: + """Invalid strings should not be cast to integer.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + ) + result = tool.cast_params({"count": "abc"}) + assert result["count"] == "abc" # Original value preserved + result = tool.cast_params({"count": "12.5.7"}) + assert result["count"] == "12.5.7" + + +def test_cast_params_invalid_string_to_number() -> None: + """Invalid strings should not be cast to number.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"rate": {"type": "number"}}, + } + ) + result = tool.cast_params({"rate": "not_a_number"}) + assert result["rate"] == "not_a_number" + + +def test_cast_params_none_values() -> None: + """Test None handling for different types.""" + tool = CastTestTool( + { + "type": "object", + "properties": { + "name": {"type": "string"}, + "count": {"type": "integer"}, + "items": {"type": "array"}, + "config": {"type": "object"}, + }, + } + ) + result = tool.cast_params( + { + "name": None, + "count": None, + "items": None, + "config": None, + } + ) + # None should be preserved for all types + assert result["name"] is None + assert result["count"] is None + assert result["items"] is None + assert result["config"] is None + + +def test_cast_params_single_value_not_auto_wrapped_to_array() -> None: + """Single values should NOT be automatically wrapped into arrays.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"items": {"type": "array"}}, + } + ) + # Non-array values should be preserved (validation will catch them) + result = tool.cast_params({"items": 5}) + assert result["items"] == 5 # Not wrapped to [5] + result = tool.cast_params({"items": "text"}) + assert result["items"] == "text" # Not wrapped to ["text"] + + +def test_cast_params_empty_string_to_array() -> None: + """Empty string should convert to empty array.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"items": {"type": "array"}}, + } + ) + result = tool.cast_params({"items": ""}) + assert result["items"] == [] + + +def test_cast_params_empty_string_to_object() -> None: + """Empty string should convert to empty object.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"config": {"type": "object"}}, + } + ) + result = tool.cast_params({"config": ""}) + assert result["config"] == {} + + +def test_cast_params_float_to_int() -> None: + """Float values should be cast to integers.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"count": {"type": "integer"}}, + } + ) + result = tool.cast_params({"count": 42.7}) + assert result["count"] == 42 + assert isinstance(result["count"], int) From 323e5f22cc1be888e2b6f291233d9a96a97edd6c Mon Sep 17 00:00:00 2001 From: suger-m Date: Thu, 5 Mar 2026 11:14:04 +0800 Subject: [PATCH 040/113] refactor(channels): extract split_message utility to reduce code duplication Extract the _split_message function from discord.py and telegram.py into a shared utility function in utils/helpers.py. Changes: - Add split_message() to nanobot/utils/helpers.py with configurable max_len - Update Discord channel to use shared utility (2000 char limit) - Update Telegram channel to use shared utility (4000 char limit) - Remove duplicate implementations from both channels Benefits: - Reduces code duplication - Centralizes message splitting logic for easier maintenance - Makes the function reusable for future channels The function splits content into chunks within max_len, preferring to break at newlines or spaces rather than mid-word. --- nanobot/channels/discord.py | 25 ++----------------------- nanobot/channels/telegram.py | 25 ++++--------------------- nanobot/utils/helpers.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 44 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 57e5922..4368540 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -13,34 +13,13 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import DiscordConfig +from nanobot.utils.helpers import split_message DISCORD_API_BASE = "https://discord.com/api/v10" MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB MAX_MESSAGE_LEN = 2000 # Discord message character limit -def _split_message(content: str, max_len: int = MAX_MESSAGE_LEN) -> list[str]: - """Split content into chunks within max_len, preferring line breaks.""" - if not content: - return [] - if len(content) <= max_len: - return [content] - chunks: list[str] = [] - while content: - if len(content) <= max_len: - chunks.append(content) - break - cut = content[:max_len] - pos = cut.rfind('\n') - if pos <= 0: - pos = cut.rfind(' ') - if pos <= 0: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() - return chunks - - class DiscordChannel(BaseChannel): """Discord channel using Gateway websocket.""" @@ -104,7 +83,7 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} try: - chunks = _split_message(msg.content or "") + chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) if not chunks: return diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index c290535..2742181 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -14,6 +14,9 @@ from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import TelegramConfig +from nanobot.utils.helpers import split_message + +TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit def _markdown_to_telegram_html(text: str) -> str: @@ -79,26 +82,6 @@ def _markdown_to_telegram_html(text: str) -> str: return text -def _split_message(content: str, max_len: int = 4000) -> list[str]: - """Split content into chunks within max_len, preferring line breaks.""" - if len(content) <= max_len: - return [content] - chunks: list[str] = [] - while content: - if len(content) <= max_len: - chunks.append(content) - break - cut = content[:max_len] - pos = cut.rfind('\n') - if pos == -1: - pos = cut.rfind(' ') - if pos == -1: - pos = max_len - chunks.append(content[:pos]) - content = content[pos:].lstrip() - return chunks - - class TelegramChannel(BaseChannel): """ Telegram channel using long polling. @@ -269,7 +252,7 @@ class TelegramChannel(BaseChannel): # Send text content if msg.content and msg.content != "[empty message]": - for chunk in _split_message(msg.content): + for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c802..8fd81f9 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -34,6 +34,38 @@ def safe_filename(name: str) -> str: return _UNSAFE_CHARS.sub("_", name).strip() +def split_message(content: str, max_len: int = 2000) -> list[str]: + """ + Split content into chunks within max_len, preferring line breaks. + + Args: + content: The text content to split. + max_len: Maximum length per chunk (default 2000 for Discord compatibility). + + Returns: + List of message chunks, each within max_len. + """ + if not content: + return [] + if len(content) <= max_len: + return [content] + chunks: list[str] = [] + while content: + if len(content) <= max_len: + chunks.append(content) + break + cut = content[:max_len] + # Try to break at newline first, then space, then hard break + pos = cut.rfind('\n') + if pos <= 0: + pos = cut.rfind(' ') + if pos <= 0: + pos = max_len + chunks.append(content[:pos]) + content = content[pos:].lstrip() + return chunks + + def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: """Sync bundled templates to workspace. Only creates missing files.""" from importlib.resources import files as pkg_files From 97522bfa0309931e53782ed7a4e2cfdc470853c4 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Thu, 5 Mar 2026 17:27:17 +0800 Subject: [PATCH 041/113] fix(feishu): isolate lark ws Client event loop from main asyncio loop Commit 0209ad5 moved `import lark_oapi as lark` inside the start() method (lazy import) to suppress DeprecationWarnings. This had an unintended side effect: the import now happens after the main asyncio loop is already running, so lark_oapi's module-level loop = asyncio.get_event_loop() captures the running main loop. When the WebSocket thread then calls loop.run_until_complete() inside Client.start(), Python raises: RuntimeError: This event loop is already running and the _connect/_disconnect coroutines are never awaited. Fix: in run_ws(), create a fresh event loop with asyncio.new_event_loop(), set it as the thread's current loop, and patch lark_oapi.ws.client.loop to point to this dedicated loop before calling Client.start(). The loop is closed on thread exit. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- nanobot/channels/feishu.py | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0cd84c3..fcb70a8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -290,16 +290,28 @@ class FeishuChannel(BaseChannel): log_level=lark.LogLevel.INFO ) - # Start WebSocket client in a separate thread with reconnect loop + # Start WebSocket client in a separate thread with reconnect loop. + # A dedicated event loop is created for this thread so that lark_oapi's + # module-level `loop = asyncio.get_event_loop()` picks up an idle loop + # instead of the already-running main asyncio loop, which would cause + # "This event loop is already running" errors. def run_ws(): - while self._running: - try: - self._ws_client.start() - except Exception as e: - logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time - time.sleep(5) + import time + import lark_oapi.ws.client as _lark_ws_client + ws_loop = asyncio.new_event_loop() + asyncio.set_event_loop(ws_loop) + # Patch the module-level loop used by lark's ws Client.start() + _lark_ws_client.loop = ws_loop + try: + while self._running: + try: + self._ws_client.start() + except Exception as e: + logger.warning("Feishu WebSocket error: {}", e) + if self._running: + time.sleep(5) + finally: + ws_loop.close() self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() From 6770a6e7e9dbebd5ca3ed490a5dd0b30b7c3d7a3 Mon Sep 17 00:00:00 2001 From: ouyangwulin Date: Thu, 5 Mar 2026 17:34:36 +0800 Subject: [PATCH 042/113] supported aliyun coding plan. --- nanobot/config/schema.py | 34 ++++++++++++++++------------------ nanobot/providers/registry.py | 6 +++--- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 538fab8..15cf2b4 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -5,7 +5,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Base(BaseModel): @@ -198,23 +198,6 @@ class QQConfig(Base): ) # Allowed user openids (empty = public access) -class MatrixConfig(Base): - """Matrix (Element) channel configuration.""" - - enabled: bool = False - homeserver: str = "https://matrix.org" - access_token: str = "" - user_id: str = "" # e.g. @bot:matrix.org - device_id: str = "" - e2ee_enabled: bool = True # end-to-end encryption support - sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout - max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit - allow_from: list[str] = Field(default_factory=list) - group_policy: Literal["open", "mention", "allowlist"] = "open" - group_allow_from: list[str] = Field(default_factory=list) - allow_room_mentions: bool = False - - class ChannelsConfig(Base): """Configuration for chat channels.""" @@ -339,6 +322,20 @@ class MCPServerConfig(Base): tool_timeout: int = 30 # Seconds before a tool call is cancelled +class TTSConfig(Base): + """Text-to-Speech configuration.""" + + provider: str = "edge_tts" # Default TTS provider + voice: str = "en-US-ChristopherNeural" # Default voice + speed: float = 1.0 # Voice speed multiplier + + +class AudioConfig(Base): + """Audio configuration.""" + + tts: TTSConfig = Field(default_factory=TTSConfig) + + class ToolsConfig(Base): """Tools configuration.""" @@ -356,6 +353,7 @@ class Config(BaseSettings): providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) + audio: AudioConfig = Field(default_factory=AudioConfig) @property def workspace_path(self) -> Path: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index da04cd7..3b6659e 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -359,15 +359,15 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( keywords=("dashscope-coding-plan", "coding-plan", "aliyun-coding", "bailian-coding"), env_key="DASHSCOPE_CODING_PLAN_API_KEY", display_name="Alibaba Cloud Coding Plan", - litellm_prefix="dashscope", # โ†’ dashscope/{model} - skip_prefixes=("dashscope/", "openrouter/"), + litellm_prefix="openai", # โ†’ openai/{model} (uses OpenAI-compatible endpoint) + skip_prefixes=("openai/", "dashscope/", "openrouter/"), env_extras=(), is_gateway=True, is_local=False, detect_by_key_prefix="sk-sp-", # coding plan API keys start with "sk-sp-" detect_by_base_keyword="coding.dashscope", default_api_base="https://coding.dashscope.aliyuncs.com/v1", - strip_model_prefix=False, + strip_model_prefix=True, # Strip "dashscope_coding_plan/" prefix model_overrides=(), ), # === Auxiliary (not a primary LLM provider) ============================ From 46192fbd2abe922390be1961819a86dc75c74321 Mon Sep 17 00:00:00 2001 From: coldxiangyu Date: Thu, 5 Mar 2026 20:18:13 +0800 Subject: [PATCH 043/113] fix(context): detect image MIME type from magic bytes instead of file extension Feishu downloads images with incorrect extensions (e.g. .jpg for PNG files). mimetypes.guess_type() relies on the file extension, causing a MIME mismatch that Anthropic rejects with 'image was specified using image/jpeg but appears to be image/png'. Fix: read the first bytes of the image data and detect the real MIME type via magic bytes (PNG: 0x89PNG, JPEG: 0xFFD8FF, GIF: GIF87a/GIF89a, WEBP: RIFF+WEBP). Fall back to mimetypes.guess_type() only when magic bytes are inconclusive. --- nanobot/agent/context.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index df4825f..7ead317 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -12,6 +12,19 @@ from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader +def _detect_image_mime(data: bytes) -> str | None: + """Detect image MIME type from magic bytes, ignoring file extension.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" @@ -136,10 +149,14 @@ Reply directly with text for conversations. Only use the 'message' tool to send images = [] for path in media: p = Path(path) - mime, _ = mimetypes.guess_type(path) - if not p.is_file() or not mime or not mime.startswith("image/"): + if not p.is_file(): continue - b64 = base64.b64encode(p.read_bytes()).decode() + raw = p.read_bytes() + # Detect real MIME type from magic bytes; fallback to filename guess + mime = _detect_image_mime(raw) or mimetypes.guess_type(path)[0] + if not mime or not mime.startswith("image/"): + continue + b64 = base64.b64encode(raw).decode() images.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}) if not images: From fb77176cfd41b50b3495ffa99cfc22bb6cbd4ed1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:25:46 +0000 Subject: [PATCH 044/113] feat(custom-provider): keep instance-level session affinity header for cache locality --- nanobot/providers/custom_provider.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 02183f3..66df734 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -16,6 +16,7 @@ class CustomProvider(LLMProvider): def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): super().__init__(api_key, api_base) self.default_model = default_model + # Keep affinity stable for this provider instance to improve backend cache locality. self._client = AsyncOpenAI( api_key=api_key, base_url=api_base, From 06fcd2cc3fed18667672f638a6c7cc54f8d5f736 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:33:14 +0000 Subject: [PATCH 045/113] fix(discord): correct group_policy default to mention and style cleanup --- nanobot/channels/discord.py | 6 ++---- nanobot/config/schema.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 85ff28a..900c17b 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -174,7 +174,7 @@ class DiscordChannel(BaseChannel): # Capture bot user ID for mention detection user_data = payload.get("user") or {} self._bot_user_id = user_data.get("id") - logger.info(f"Discord bot connected as user {self._bot_user_id}") + logger.info("Discord bot connected as user {}", self._bot_user_id) elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) elif op == 7: @@ -287,8 +287,6 @@ class DiscordChannel(BaseChannel): def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: """Check if bot should respond in a group channel based on policy.""" - channel_id = str(payload.get("channel_id", "")) - if self.config.group_policy == "open": return True @@ -303,7 +301,7 @@ class DiscordChannel(BaseChannel): # Also check content for mention format <@USER_ID> if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content: return True - logger.debug(f"Discord message in {channel_id} ignored (bot not mentioned)") + logger.debug("Discord message in {} ignored (bot not mentioned)", payload.get("channel_id")) return False return True diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index a6b609b..9d7da3b 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -62,7 +62,7 @@ class DiscordConfig(Base): 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 - group_policy: str = "open" # "mention" or "open" + group_policy: Literal["mention", "open"] = "mention" class MatrixConfig(Base): From b71c1bdca7dd0aa6323d7b8074bf4be25aa44a9b Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:44:45 +0000 Subject: [PATCH 046/113] fix(mcp): hoist sse/http imports, annotate auto-detection heuristic, restore field comments --- README.md | 4 ++-- nanobot/agent/tools/mcp.py | 6 +++--- nanobot/config/schema.py | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 6c9304d..5bc70b8 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ ๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw) -โšก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ€” **99% smaller** than Clawdbot's 430k+ lines. +โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw, making it more customizable and understandable. -๐Ÿ“ Real-time line count: **3,935 lines** (run `bash core_agent_lines.sh` to verify anytime) +๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime ## ๐Ÿ“ข News diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 151aa55..2cbffd0 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -58,7 +58,9 @@ async def connect_mcp_servers( ) -> None: """Connect to configured MCP servers and register their tools.""" from mcp import ClientSession, StdioServerParameters + from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client + from mcp.client.streamable_http import streamable_http_client for name, cfg in mcp_servers.items(): try: @@ -67,6 +69,7 @@ async def connect_mcp_servers( if cfg.command: transport_type = "stdio" elif cfg.url: + # Convention: URLs ending with /sse use SSE transport; others use streamableHttp transport_type = ( "sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp" ) @@ -80,8 +83,6 @@ async def connect_mcp_servers( ) read, write = await stack.enter_async_context(stdio_client(params)) elif transport_type == "sse": - from mcp.client.sse import sse_client - def httpx_client_factory( headers: dict[str, str] | None = None, timeout: httpx.Timeout | None = None, @@ -99,7 +100,6 @@ async def connect_mcp_servers( sse_client(cfg.url, httpx_client_factory=httpx_client_factory) ) elif transport_type == "streamableHttp": - from mcp.client.streamable_http import streamable_http_client # Always provide an explicit httpx client so MCP HTTP transport does not # inherit httpx's default 5s timeout and preempt the higher-level tool timeout. http_client = await stack.enter_async_context( diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9f2e5b3..1f2f946 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -329,13 +329,13 @@ class ExecToolConfig(Base): class MCPServerConfig(Base): """MCP server connection configuration (stdio or HTTP).""" - type: Literal["stdio", "sse", "streamableHttp"] | None = None - command: str = "" - args: list[str] = Field(default_factory=list) - env: dict[str, str] = Field(default_factory=dict) - url: str = "" - headers: dict[str, str] = Field(default_factory=dict) - tool_timeout: int = 30 + type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted + 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/SSE: endpoint URL + headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers + tool_timeout: int = 30 # seconds before a tool call is cancelled class ToolsConfig(Base): From 57d8aefc2289144339640be677d5d4e3edfdcb6f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:46:03 +0000 Subject: [PATCH 047/113] docs: update introduction of nanobot --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5bc70b8..4c5e9a6 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,11 @@

-๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw) +๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw). โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw, making it more customizable and understandable. -๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime +๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime. ## ๐Ÿ“ข News From cd0bcc162e5a742e452918c4835384774d7a7938 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:48:57 +0000 Subject: [PATCH 048/113] docs: update introduction of nanobot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4c5e9a6..1374fb8 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ ๐Ÿˆ **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw). -โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw, making it more customizable and understandable. +โšก๏ธ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw. ๐Ÿ“ Real-time line count: run `bash core_agent_lines.sh` to verify anytime. From 0343d66224007d6d7964984db7741ae710c81167 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 14:54:53 +0000 Subject: [PATCH 049/113] fix(gateway): remove duplicate load_config() that overwrote custom workspace/config --- nanobot/cli/commands.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 05e2cbe..b097059 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -244,7 +244,7 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), - workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory (default: ~/.nanobot/workspace)"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), ): @@ -252,7 +252,7 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.loader import get_data_dir, load_config + from nanobot.config.loader import load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -262,17 +262,12 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) - # Load config from custom path if provided, otherwise use default config_path = Path(config) if config else None config = load_config(config_path) - - # Override workspace if specified via command line if workspace: config.agents.defaults.workspace = workspace console.print(f"{__logo__} Starting nanobot gateway on port {port}...") - - config = load_config() sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) From d32c6f946c5fd030ddfbbb645adb43b84a43d6ed Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 5 Mar 2026 15:17:30 +0000 Subject: [PATCH 050/113] fix(telegram): pin ptb>=22.6, fix double progress, clean up stale hatch config --- nanobot/agent/loop.py | 14 ++++++-------- nanobot/channels/telegram.py | 2 +- pyproject.toml | 5 +---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 2f6a2bc..7f129a2 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -205,18 +205,16 @@ class AgentLoop: thoughts = [ self._strip_think(response.content), response.reasoning_content, - *(f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" - for b in (response.thinking_blocks or []) if isinstance(b, dict) and "signature" in b) + *( + f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" + for b in (response.thinking_blocks or []) + if isinstance(b, dict) and "signature" in b + ), ] - combined_thoughts = "\n\n".join(filter(None, thoughts)) - tool_hint_str = self._tool_hint(response.tool_calls) - if combined_thoughts: await on_progress(combined_thoughts) - await on_progress(f"{combined_thoughts}\n\n{tool_hint_str}", tool_hint=True) - else: - await on_progress(tool_hint_str, tool_hint=True) + await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ { diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index de95a15..884b2d0 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -292,7 +292,7 @@ class TelegramChannel(BaseChannel): reply_parameters=reply_params ) except Exception as e: - logger.warning("HTML parse failed (or draft send failed), falling back to plain text: {}", e) + logger.warning("HTML parse failed, falling back to plain text: {}", e) try: if is_progress and draft_id: await self._app.bot.send_message_draft( diff --git a/pyproject.toml b/pyproject.toml index 674a1ef..41d0fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dependencies = [ "rich>=14.0.0,<15.0.0", "croniter>=6.0.0,<7.0.0", "dingtalk-stream>=0.24.0,<1.0.0", - "python-telegram-bot[socks]>=22.0,<23.0", + "python-telegram-bot[socks]>=22.6,<23.0", "lark-oapi>=1.5.0,<2.0.0", "socksio>=1.0.0,<2.0.0", "python-socketio>=5.16.0,<6.0.0", @@ -68,9 +68,6 @@ nanobot = "nanobot.cli.commands:app" requires = ["hatchling"] build-backend = "hatchling.build" -[tool.hatch.metadata] -allow-direct-references = true - [tool.hatch.build.targets.wheel] packages = ["nanobot"] From 4e4d40ef333177da2aca9540d8d3f8266c6725d5 Mon Sep 17 00:00:00 2001 From: samsonchoi Date: Thu, 5 Mar 2026 23:48:45 +0800 Subject: [PATCH 051/113] feat: multi-instance support with --config parameter Add support for running multiple nanobot instances with complete isolation: - Add --config parameter to gateway command for custom config file path - Implement set_config_path() in config/loader.py for dynamic config path - Derive data directory from config file location (e.g., ~/.nanobot-xxx/) - Update get_data_path() to use unified data directory from config loader - Ensure cron jobs use instance-specific data directory This enables running multiple isolated nanobot instances by specifying different config files, with each instance maintaining separate: - Configuration files - Workspace (memory, sessions, skills) - Cron jobs - Logs and media Example usage: nanobot gateway --config ~/.nanobot-instance2/config.json --port 18791 --- nanobot/cli/commands.py | 25 +++++++++++++++---------- nanobot/config/loader.py | 22 ++++++++++++++++++---- nanobot/utils/helpers.py | 5 +++-- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aca0778..097e41c 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -244,15 +244,24 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), - workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), - config: str | None = typer.Option(None, "--config", "-c", help="Config file path"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), + config: str = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Start the nanobot gateway.""" + # Set config path if provided (must be done before any imports that use get_data_dir) + if config: + from nanobot.config.loader import set_config_path + config_path = Path(config).expanduser().resolve() + if not config_path.exists(): + console.print(f"[red]Error: Config file not found: {config_path}[/red]") + raise typer.Exit(1) + set_config_path(config_path) + console.print(f"[dim]Using config: {config_path}[/dim]") + from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.loader import load_config + from nanobot.config.loader import get_data_dir, load_config from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -262,20 +271,16 @@ def gateway( import logging logging.basicConfig(level=logging.DEBUG) - config_path = Path(config) if config else None - config = load_config(config_path) - if workspace: - config.agents.defaults.workspace = workspace - console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + + config = load_config() sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) # Create cron service first (callback set after agent creation) - # Use workspace path for per-instance cron store - cron_store_path = config.workspace_path / "cron" / "jobs.json" + cron_store_path = get_data_dir() / "cron" / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index c789efd..4355bd3 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -6,15 +6,29 @@ from pathlib import Path from nanobot.config.schema import Config +# Global variable to store current config path (for multi-instance support) +_current_config_path: Path | None = None + + +def set_config_path(path: Path) -> None: + """Set the current config path (used to derive data directory).""" + global _current_config_path + _current_config_path = path + + def get_config_path() -> Path: - """Get the default configuration file path.""" + """Get the configuration file path.""" + if _current_config_path: + return _current_config_path return Path.home() / ".nanobot" / "config.json" def get_data_dir() -> Path: - """Get the nanobot data directory.""" - from nanobot.utils.helpers import get_data_path - return get_data_path() + """Get the nanobot data directory (derived from config path).""" + config_path = get_config_path() + # If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/ + # If config is ~/.nanobot/config.json, data dir is ~/.nanobot/ + return config_path.parent def load_config(config_path: Path | None = None) -> Config: diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c802..e244829 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -12,8 +12,9 @@ def ensure_dir(path: Path) -> Path: def get_data_path() -> Path: - """~/.nanobot data directory.""" - return ensure_dir(Path.home() / ".nanobot") + """Get nanobot data directory (derived from config path).""" + from nanobot.config.loader import get_data_dir + return ensure_dir(get_data_dir()) def get_workspace_path(workspace: str | None = None) -> Path: From 958c23fb0120d4e24e803e60839f2063d1ddf93e Mon Sep 17 00:00:00 2001 From: VITOHJL Date: Thu, 5 Mar 2026 23:45:20 +0800 Subject: [PATCH 052/113] chore: refine platform policy and memory SKILL docs --- nanobot/agent/context.py | 28 ++++++++++++++++++++++++++++ nanobot/skills/memory/SKILL.md | 15 ++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index df4825f..9254a7f 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -58,6 +58,32 @@ Skills with available="false" need dependencies installed first - you can try in system = platform.system() runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" + platform_policy = "" + if system == "Windows": + platform_policy = """## Platform Policy (Windows) +- You are running on Windows. Shell commands executed via the `exec` tool run under the default Windows shell (PowerShell or cmd.exe) unless you explicitly invoke another shell. +- Prefer UTF-8 for file I/O and command output. If terminal output is garbled/mojibake, retry with: + - PowerShell: `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ` + - cmd.exe: `chcp 65001 >NUL & ` +- Do NOT assume GNU tools like `grep`, `sed`, `awk` exist. Prefer Windows built-ins: + - Search text: `findstr /i "keyword" path\\to\\file` + - List files: `dir` + - Show file: `type path\\to\\file` +- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reliability. +""" + elif system == "Darwin": + platform_policy = """## Platform Policy (macOS) +- You are running on macOS. Prefer POSIX tools and UTF-8. +- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. +- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. +""" + else: + platform_policy = """## Platform Policy (Linux) +- You are running on Linux. Prefer POSIX tools and UTF-8. +- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. +- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. +""" + return f"""# nanobot ๐Ÿˆ You are nanobot, a helpful AI assistant. @@ -71,6 +97,8 @@ Your workspace is at: {workspace_path} - History log: {workspace_path}/memory/HISTORY.md (grep-searchable). Each entry starts with [YYYY-MM-DD HH:MM]. - Custom skills: {workspace_path}/skills/{{skill-name}}/SKILL.md +{platform_policy} + ## nanobot Guidelines - State intent before tool calls, but NEVER predict or claim results before receiving them. - Before modifying a file, read it first. Do not assume files or directories exist. diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index 529a02d..865f11f 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -9,15 +9,20 @@ always: true ## Structure - `memory/MEMORY.md` โ€” Long-term facts (preferences, project context, relationships). Always loaded into your context. -- `memory/HISTORY.md` โ€” Append-only event log. NOT loaded into context. Search it with grep. Each entry starts with [YYYY-MM-DD HH:MM]. +- `memory/HISTORY.md` โ€” Append-only event log. NOT loaded into context. Search it with grep-style tools or in-memory filters. Each entry starts with [YYYY-MM-DD HH:MM]. ## Search Past Events -```bash -grep -i "keyword" memory/HISTORY.md -``` +**Recommended approach (cross-platform):** +- Use `read_file` to read `memory/HISTORY.md`, then search in-memory +- This is the most reliable and portable method on all platforms -Use the `exec` tool to run grep. Combine patterns: `grep -iE "meeting|deadline" memory/HISTORY.md` +**Alternative (if you need command-line search):** +- **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md` +- **Windows:** `findstr /i "keyword" memory\HISTORY.md` +- **Python (cross-platform):** `python -c "import re; content=open('memory/HISTORY.md', encoding='utf-8').read(); print('\n'.join([l for l in content.split('\n') if 'keyword' in l.lower()][-20:]))"` + +Use the `exec` tool to run these commands. For complex searches, prefer `read_file` + in-memory filtering. ## When to Update MEMORY.md From 5ced08b1f23f5ef275465fbe3140f64d42c95ced Mon Sep 17 00:00:00 2001 From: pikaqqqqqq Date: Fri, 6 Mar 2026 01:54:00 +0800 Subject: [PATCH 053/113] fix(feishu): use msg_type "media" for mp4 video files Previously, mp4 video files were sent with msg_type "file", which meant users had to download them to play. Feishu requires msg_type "media" for audio and video files to enable inline playback in the chat. Changes: - Add _VIDEO_EXTS constant for video file extensions (.mp4, .mov, .avi) - Use msg_type "media" for both audio (_AUDIO_EXTS) and video (_VIDEO_EXTS) - Keep msg_type "file" for documents and other file types The upload_file API already uses file_type="mp4" for video files via the existing _FILE_TYPE_MAP, so only the send msg_type needed fixing. --- nanobot/channels/feishu.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e6f0049..3847ac1 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -474,6 +474,7 @@ class FeishuChannel(BaseChannel): _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus"} + _VIDEO_EXTS = {".mp4", ".mov", ".avi"} _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", @@ -682,7 +683,12 @@ class FeishuChannel(BaseChannel): else: key = await loop.run_in_executor(None, self._upload_file_sync, file_path) if key: - media_type = "audio" if ext in self._AUDIO_EXTS else "file" + # Use msg_type "media" for audio/video so users can play inline; + # "file" for everything else (documents, archives, etc.) + if ext in self._AUDIO_EXTS or ext in self._VIDEO_EXTS: + media_type = "media" + else: + media_type = "file" await loop.run_in_executor( None, self._send_message_sync, receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False), From 9ab4155991627e45dd2c88b028d35c55b82ecce9 Mon Sep 17 00:00:00 2001 From: nanobot-contributor Date: Fri, 6 Mar 2026 09:57:03 +0800 Subject: [PATCH 054/113] fix(cli): add Windows compatibility for signal handlers (PR #1400) SIGHUP and SIGPIPE are not available on Windows. Add hasattr() checks before registering these signal handlers to prevent AttributeError on Windows systems. Fixes compatibility issue introduced in PR #1400. --- nanobot/cli/commands.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aca0778..eb3d833 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -7,6 +7,18 @@ import signal import sys from pathlib import Path +# Force UTF-8 encoding for Windows console +if sys.platform == "win32": + import locale + if sys.stdout.encoding != "utf-8": + os.environ["PYTHONIOENCODING"] = "utf-8" + # Re-open stdout/stderr with UTF-8 encoding + try: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + except Exception: + pass + import typer from prompt_toolkit import PromptSession from prompt_toolkit.formatted_text import HTML @@ -525,9 +537,13 @@ def agent( signal.signal(signal.SIGINT, _handle_signal) signal.signal(signal.SIGTERM, _handle_signal) - signal.signal(signal.SIGHUP, _handle_signal) + # SIGHUP is not available on Windows + if hasattr(signal, 'SIGHUP'): + signal.signal(signal.SIGHUP, _handle_signal) # Ignore SIGPIPE to prevent silent process termination when writing to closed pipes - signal.signal(signal.SIGPIPE, signal.SIG_IGN) + # SIGPIPE is not available on Windows + if hasattr(signal, 'SIGPIPE'): + signal.signal(signal.SIGPIPE, signal.SIG_IGN) async def run_interactive(): bus_task = asyncio.create_task(agent_loop.run()) From c3526a7fdb2418d68c03d34db5ee43b624edbce9 Mon Sep 17 00:00:00 2001 From: PiKaqqqqqq <281705236@qq.com> Date: Fri, 6 Mar 2026 10:11:53 +0800 Subject: [PATCH 055/113] fix(feishu): smart message format selection (fixes #1548) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of always sending interactive cards, detect the optimal message format based on content: - text: short plain text (โ‰ค200 chars, no markdown) - post: medium text with links (โ‰ค2000 chars) - interactive: complex content (code, tables, headings, bold, lists) --- nanobot/channels/feishu.py | 143 +++++++++++++++++++++++++++++++++++-- pr-description.md | 47 ++++++++++++ 2 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 pr-description.md diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index e6f0049..c405493 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -472,6 +472,121 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] + # โ”€โ”€ Smart format detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + # Patterns that indicate "complex" markdown needing card rendering + _COMPLEX_MD_RE = re.compile( + r"```" # fenced code block + r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator) + r"|^#{1,6}\s+" # headings + , re.MULTILINE, + ) + + # Simple markdown patterns (bold, italic, strikethrough) + _SIMPLE_MD_RE = re.compile( + r"\*\*.+?\*\*" # **bold** + r"|__.+?__" # __bold__ + r"|(? str: + """Determine the optimal Feishu message format for *content*. + + Returns one of: + - ``"text"`` โ€“ plain text, short and no markdown + - ``"post"`` โ€“ rich text (links only, moderate length) + - ``"interactive"`` โ€“ card with full markdown rendering + """ + stripped = content.strip() + + # Complex markdown (code blocks, tables, headings) โ†’ always card + if cls._COMPLEX_MD_RE.search(stripped): + return "interactive" + + # Long content โ†’ card (better readability with card layout) + if len(stripped) > cls._POST_MAX_LEN: + return "interactive" + + # Has bold/italic/strikethrough โ†’ card (post format can't render these) + if cls._SIMPLE_MD_RE.search(stripped): + return "interactive" + + # Has list items โ†’ card (post format can't render list bullets well) + if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped): + return "interactive" + + # Has links โ†’ post format (supports tags) + if cls._MD_LINK_RE.search(stripped): + return "post" + + # Short plain text โ†’ text format + if len(stripped) <= cls._TEXT_MAX_LEN: + return "text" + + # Medium plain text without any formatting โ†’ post format + return "post" + + @classmethod + def _markdown_to_post(cls, content: str) -> str: + """Convert markdown content to Feishu post message JSON. + + Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags. + Each line becomes a paragraph (row) in the post body. + """ + lines = content.strip().split("\n") + paragraphs: list[list[dict]] = [] + + for line in lines: + elements: list[dict] = [] + last_end = 0 + + for m in cls._MD_LINK_RE.finditer(line): + # Text before this link + before = line[last_end:m.start()] + if before: + elements.append({"tag": "text", "text": before}) + elements.append({ + "tag": "a", + "text": m.group(1), + "href": m.group(2), + }) + last_end = m.end() + + # Remaining text after last link + remaining = line[last_end:] + if remaining: + elements.append({"tag": "text", "text": remaining}) + + # Empty line โ†’ empty paragraph for spacing + if not elements: + elements.append({"tag": "text", "text": ""}) + + paragraphs.append(elements) + + post_body = { + "zh_cn": { + "content": paragraphs, + } + } + return json.dumps(post_body, ensure_ascii=False) + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} _AUDIO_EXTS = {".opus"} _FILE_TYPE_MAP = { @@ -689,14 +804,34 @@ class FeishuChannel(BaseChannel): ) if msg.content and msg.content.strip(): - elements = self._build_card_elements(msg.content) - for chunk in self._split_elements_by_table_limit(elements): - card = {"config": {"wide_screen_mode": True}, "elements": chunk} + fmt = self._detect_msg_format(msg.content) + + if fmt == "text": + # Short plain text โ€“ send as simple text message + text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False) await loop.run_in_executor( None, self._send_message_sync, - receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + receive_id_type, msg.chat_id, "text", text_body, ) + elif fmt == "post": + # Medium content with links โ€“ send as rich-text post + post_body = self._markdown_to_post(msg.content) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "post", post_body, + ) + + else: + # Complex / long content โ€“ send as interactive card + elements = self._build_card_elements(msg.content) + for chunk in self._split_elements_by_table_limit(elements): + card = {"config": {"wide_screen_mode": True}, "elements": chunk} + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), + ) + except Exception as e: logger.error("Error sending Feishu message: {}", e) diff --git a/pr-description.md b/pr-description.md new file mode 100644 index 0000000..dacab5c --- /dev/null +++ b/pr-description.md @@ -0,0 +1,47 @@ +## fix(feishu): smart message format selection (fixes #1548) + +### Problem + +Currently, the Feishu channel sends **all** messages as interactive cards (`msg_type: "interactive"`). This is overkill for short, simple replies like "OK" or "ๆ”ถๅˆฐ" โ€” they look heavy and unnatural compared to normal chat messages. + +### Solution + +Implement smart message format selection that picks the most appropriate Feishu message type based on content analysis: + +| Content Type | Format | `msg_type` | +|---|---|---| +| Short plain text (โ‰ค 200 chars, no markdown) | Text | `text` | +| Medium text with links (โ‰ค 2000 chars, no complex formatting) | Rich Text Post | `post` | +| Long text, code blocks, tables, headings, bold/italic, lists | Interactive Card | `interactive` | + +### How it works + +1. **`_detect_msg_format(content)`** โ€” Analyzes the message content and returns the optimal format: + - Checks for complex markdown (code blocks, tables, headings) โ†’ `interactive` + - Checks for simple markdown (bold, italic, lists) โ†’ `interactive` + - Checks for links โ†’ `post` (Feishu post format supports `` tags natively) + - Short plain text โ†’ `text` + - Medium plain text โ†’ `post` + +2. **`_markdown_to_post(content)`** โ€” Converts markdown links `[text](url)` to Feishu post format with proper `a` tags. Each line becomes a paragraph in the post body. + +3. **Modified `send()` method** โ€” Uses `_detect_msg_format()` to choose the right format, then dispatches to the appropriate sending logic. + +### Design decisions + +- **Post format for links only**: Feishu's post format (`[[{"tag":"text",...}]]`) doesn't support bold/italic rendering, so we only use it for messages containing links (where the `a` tag adds real value). Messages with bold/italic/lists still use cards which render markdown properly. +- **Conservative thresholds**: 200 chars for text, 2000 chars for post โ€” these keep the UX natural without being too aggressive. +- **Backward compatible**: The card rendering path is completely unchanged. Only the routing logic is new. + +### Testing + +Format detection tested against 13 cases covering all content types: +- โœ… Plain text โ†’ `text` +- โœ… Links โ†’ `post` +- โœ… Bold/italic/code/tables/headings/lists โ†’ `interactive` +- โœ… Long content โ†’ `interactive` +- โœ… Post format generates valid Feishu post JSON with proper `a` tags + +### Changes + +- `nanobot/channels/feishu.py`: Added `_detect_msg_format()`, `_markdown_to_post()`, and updated `send()` method From 6fb4204ac6a5109a4ff068a17975615498c40c05 Mon Sep 17 00:00:00 2001 From: nanobot-contributor Date: Fri, 6 Mar 2026 11:47:00 +0800 Subject: [PATCH 056/113] fix(memory): handle list type tool call arguments Some LLM providers return tool_calls[0].arguments as a list instead of dict or str. Add handling to extract the first dict element from the list. Fixes /new command warning: 'unexpected arguments type list' --- nanobot/agent/memory.py | 7 +++ tests/test_memory_consolidation_types.py | 75 ++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 93c1825..80fba5e 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -128,6 +128,13 @@ class MemoryStore: # Some providers return arguments as a JSON string instead of dict if isinstance(args, str): args = json.loads(args) + # Some providers return arguments as a list (handle edge case) + if isinstance(args, list): + if args and isinstance(args[0], dict): + args = args[0] + else: + logger.warning("Memory consolidation: unexpected arguments type list with non-dict content") + return False if not isinstance(args, dict): logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__) return False diff --git a/tests/test_memory_consolidation_types.py b/tests/test_memory_consolidation_types.py index 375c802..ff15584 100644 --- a/tests/test_memory_consolidation_types.py +++ b/tests/test_memory_consolidation_types.py @@ -145,3 +145,78 @@ class TestMemoryConsolidationTypeHandling: assert result is True provider.chat.assert_not_called() + + @pytest.mark.asyncio + async def test_list_arguments_extracts_first_dict(self, tmp_path: Path) -> None: + """Some providers return arguments as a list - extract first element if it's a dict.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + # Simulate arguments being a list containing a dict + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=[{ + "history_entry": "[2026-01-01] User discussed testing.", + "memory_update": "# Memory\nUser likes testing.", + }], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is True + assert "User discussed testing." in store.history_file.read_text() + assert "User likes testing." in store.memory_file.read_text() + + @pytest.mark.asyncio + async def test_list_arguments_empty_list_returns_false(self, tmp_path: Path) -> None: + """Empty list arguments should return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=[], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False + + @pytest.mark.asyncio + async def test_list_arguments_non_dict_content_returns_false(self, tmp_path: Path) -> None: + """List with non-dict content should return False.""" + store = MemoryStore(tmp_path) + provider = AsyncMock() + + response = LLMResponse( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="save_memory", + arguments=["string", "content"], + ) + ], + ) + provider.chat = AsyncMock(return_value=response) + session = _make_session(message_count=60) + + result = await store.consolidate(session, provider, "test-model", memory_window=50) + + assert result is False From fc0b38c3047c20241c94b38f1be6138191da41f6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 05:27:39 +0000 Subject: [PATCH 057/113] fix(memory): improve warning message for empty/non-dict list arguments --- nanobot/agent/memory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/memory.py b/nanobot/agent/memory.py index 80fba5e..21fe77d 100644 --- a/nanobot/agent/memory.py +++ b/nanobot/agent/memory.py @@ -133,7 +133,7 @@ class MemoryStore: if args and isinstance(args[0], dict): args = args[0] else: - logger.warning("Memory consolidation: unexpected arguments type list with non-dict content") + logger.warning("Memory consolidation: unexpected arguments as empty or non-dict list") return False if not isinstance(args, dict): logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__) From ba63f6f62d9b2181b56863d8efe32215fe8f6321 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 06:09:46 +0000 Subject: [PATCH 058/113] chore: remove pr-description.md from repo --- pr-description.md | 47 ----------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100644 pr-description.md diff --git a/pr-description.md b/pr-description.md deleted file mode 100644 index dacab5c..0000000 --- a/pr-description.md +++ /dev/null @@ -1,47 +0,0 @@ -## fix(feishu): smart message format selection (fixes #1548) - -### Problem - -Currently, the Feishu channel sends **all** messages as interactive cards (`msg_type: "interactive"`). This is overkill for short, simple replies like "OK" or "ๆ”ถๅˆฐ" โ€” they look heavy and unnatural compared to normal chat messages. - -### Solution - -Implement smart message format selection that picks the most appropriate Feishu message type based on content analysis: - -| Content Type | Format | `msg_type` | -|---|---|---| -| Short plain text (โ‰ค 200 chars, no markdown) | Text | `text` | -| Medium text with links (โ‰ค 2000 chars, no complex formatting) | Rich Text Post | `post` | -| Long text, code blocks, tables, headings, bold/italic, lists | Interactive Card | `interactive` | - -### How it works - -1. **`_detect_msg_format(content)`** โ€” Analyzes the message content and returns the optimal format: - - Checks for complex markdown (code blocks, tables, headings) โ†’ `interactive` - - Checks for simple markdown (bold, italic, lists) โ†’ `interactive` - - Checks for links โ†’ `post` (Feishu post format supports `` tags natively) - - Short plain text โ†’ `text` - - Medium plain text โ†’ `post` - -2. **`_markdown_to_post(content)`** โ€” Converts markdown links `[text](url)` to Feishu post format with proper `a` tags. Each line becomes a paragraph in the post body. - -3. **Modified `send()` method** โ€” Uses `_detect_msg_format()` to choose the right format, then dispatches to the appropriate sending logic. - -### Design decisions - -- **Post format for links only**: Feishu's post format (`[[{"tag":"text",...}]]`) doesn't support bold/italic rendering, so we only use it for messages containing links (where the `a` tag adds real value). Messages with bold/italic/lists still use cards which render markdown properly. -- **Conservative thresholds**: 200 chars for text, 2000 chars for post โ€” these keep the UX natural without being too aggressive. -- **Backward compatible**: The card rendering path is completely unchanged. Only the routing logic is new. - -### Testing - -Format detection tested against 13 cases covering all content types: -- โœ… Plain text โ†’ `text` -- โœ… Links โ†’ `post` -- โœ… Bold/italic/code/tables/headings/lists โ†’ `interactive` -- โœ… Long content โ†’ `interactive` -- โœ… Post format generates valid Feishu post JSON with proper `a` tags - -### Changes - -- `nanobot/channels/feishu.py`: Added `_detect_msg_format()`, `_markdown_to_post()`, and updated `send()` method From 3a01fe536a37c8424fc196b1b0aad3535a50af93 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 06:49:09 +0000 Subject: [PATCH 059/113] refactor: move detect_image_mime to utils/helpers for reuse --- nanobot/agent/context.py | 16 ++-------------- nanobot/utils/helpers.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 7ead317..27511fa 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -10,19 +10,7 @@ from typing import Any from nanobot.agent.memory import MemoryStore from nanobot.agent.skills import SkillsLoader - - -def _detect_image_mime(data: bytes) -> str | None: - """Detect image MIME type from magic bytes, ignoring file extension.""" - if data[:8] == b"\x89PNG\r\n\x1a\n": - return "image/png" - if data[:3] == b"\xff\xd8\xff": - return "image/jpeg" - if data[:6] in (b"GIF87a", b"GIF89a"): - return "image/gif" - if data[:4] == b"RIFF" and data[8:12] == b"WEBP": - return "image/webp" - return None +from nanobot.utils.helpers import detect_image_mime class ContextBuilder: @@ -153,7 +141,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send continue raw = p.read_bytes() # Detect real MIME type from magic bytes; fallback to filename guess - mime = _detect_image_mime(raw) or mimetypes.guess_type(path)[0] + mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] if not mime or not mime.startswith("image/"): continue b64 = base64.b64encode(raw).decode() diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 3a8c802..b543174 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -5,6 +5,19 @@ from datetime import datetime from pathlib import Path +def detect_image_mime(data: bytes) -> str | None: + """Detect image MIME type from magic bytes, ignoring file extension.""" + if data[:8] == b"\x89PNG\r\n\x1a\n": + return "image/png" + if data[:3] == b"\xff\xd8\xff": + return "image/jpeg" + if data[:6] in (b"GIF87a", b"GIF89a"): + return "image/gif" + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return "image/webp" + return None + + def ensure_dir(path: Path) -> Path: """Ensure directory exists, return it.""" path.mkdir(parents=True, exist_ok=True) From b817463939c9529ab119fec1c7be89dd2da68606 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 07:13:04 +0000 Subject: [PATCH 060/113] chore: simplify Alibaba Coding Plan to apiBase hint, remove dedicated provider --- README.md | 2 +- nanobot/config/schema.py | 28 +++------------------------- nanobot/providers/registry.py | 20 -------------------- 3 files changed, 4 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 627bb37..0c49608 100644 --- a/README.md +++ b/README.md @@ -662,9 +662,9 @@ Config file: `~/.nanobot/config.json` > [!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. -> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian coding assistance), add configuration for `dashscope_coding_plan` provider with an API key starting with `sk-sp-` in your config. This provider uses OpenAI-compatible endpoint `https://coding.dashscope.aliyuncs.com/v1`. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **VolcEngine Coding Plan**: If you're on VolcEngine's coding plan, set `"apiBase": "https://ark.cn-beijing.volces.com/api/coding/v3"` in your volcengine provider config. +> - **Alibaba Cloud Coding Plan**: If you're on the Alibaba Cloud Coding Plan (BaiLian), set `"apiBase": "https://coding.dashscope.aliyuncs.com/v1"` in your dashscope provider config. | Provider | Purpose | Get API Key | |----------|---------|-------------| diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index e80c8d0..2073eeb 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -5,7 +5,7 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, Field from pydantic.alias_generators import to_camel -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings class Base(BaseModel): @@ -258,20 +258,13 @@ class ProvidersConfig(Base): groq: ProviderConfig = Field(default_factory=ProviderConfig) zhipu: ProviderConfig = Field(default_factory=ProviderConfig) dashscope: ProviderConfig = Field(default_factory=ProviderConfig) # ้˜ฟ้‡Œไบ‘้€šไน‰ๅƒ้—ฎ - dashscope_coding_plan: ProviderConfig = Field( - default_factory=ProviderConfig - ) # ้˜ฟ้‡Œไบ‘็™พ็‚ผCoding Plan vllm: ProviderConfig = Field(default_factory=ProviderConfig) gemini: ProviderConfig = Field(default_factory=ProviderConfig) moonshot: ProviderConfig = Field(default_factory=ProviderConfig) minimax: ProviderConfig = Field(default_factory=ProviderConfig) aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway - siliconflow: ProviderConfig = Field( - default_factory=ProviderConfig - ) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) API gateway - volcengine: ProviderConfig = Field( - default_factory=ProviderConfig - ) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) API gateway + siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (็ก…ๅŸบๆตๅŠจ) + volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (็ซๅฑฑๅผ•ๆ“Ž) openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth) github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth) @@ -326,20 +319,6 @@ class MCPServerConfig(Base): tool_timeout: int = 30 # seconds before a tool call is cancelled -class TTSConfig(Base): - """Text-to-Speech configuration.""" - - provider: str = "edge_tts" # Default TTS provider - voice: str = "en-US-ChristopherNeural" # Default voice - speed: float = 1.0 # Voice speed multiplier - - -class AudioConfig(Base): - """Audio configuration.""" - - tts: TTSConfig = Field(default_factory=TTSConfig) - - class ToolsConfig(Base): """Tools configuration.""" @@ -357,7 +336,6 @@ class Config(BaseSettings): providers: ProvidersConfig = Field(default_factory=ProvidersConfig) gateway: GatewayConfig = Field(default_factory=GatewayConfig) tools: ToolsConfig = Field(default_factory=ToolsConfig) - audio: AudioConfig = Field(default_factory=AudioConfig) @property def workspace_path(self) -> Path: diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 3b6659e..59ba31a 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -350,26 +350,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( strip_model_prefix=False, model_overrides=(), ), - # === Coding Plan Gateway Providers ===================================== - # Alibaba Cloud Coding Plan: OpenAI-compatible gateway for coding assistance. - # Uses special API key format starting with "sk-sp-" to distinguish it - # from regular dashscope keys. Uses the OpenAI-compatible endpoint. - ProviderSpec( - name="dashscope_coding_plan", - keywords=("dashscope-coding-plan", "coding-plan", "aliyun-coding", "bailian-coding"), - env_key="DASHSCOPE_CODING_PLAN_API_KEY", - display_name="Alibaba Cloud Coding Plan", - litellm_prefix="openai", # โ†’ openai/{model} (uses OpenAI-compatible endpoint) - skip_prefixes=("openai/", "dashscope/", "openrouter/"), - env_extras=(), - is_gateway=True, - is_local=False, - detect_by_key_prefix="sk-sp-", # coding plan API keys start with "sk-sp-" - detect_by_base_keyword="coding.dashscope", - default_api_base="https://coding.dashscope.aliyuncs.com/v1", - strip_model_prefix=True, # Strip "dashscope_coding_plan/" prefix - model_overrides=(), - ), # === Auxiliary (not a primary LLM provider) ============================ # Groq: mainly used for Whisper voice transcription, also usable for LLM. # Needs "groq/" prefix for LiteLLM routing. Placed last โ€” it rarely wins fallback. From dcebb94b014cd80dd93e80acad0664cf708ae767 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 07:16:20 +0000 Subject: [PATCH 061/113] style: remove trailing whitespace --- nanobot/providers/litellm_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index a1819a2..620424e 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -298,7 +298,7 @@ class LiteLLMProvider(LLMProvider): reasoning_content = getattr(message, "reasoning_content", None) or None thinking_blocks = getattr(message, "thinking_blocks", None) or None - + return LLMResponse( content=content, tool_calls=tool_calls, From 813d37ad35ad63e8e66ec4ea1ccbef23b43f06df Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 08:43:58 +0000 Subject: [PATCH 062/113] Support Azure OpenAI --- nanobot/cli/commands.py | 15 + nanobot/config/schema.py | 1 + nanobot/providers/__init__.py | 3 +- nanobot/providers/azure_openai_provider.py | 192 +++++++++++ nanobot/providers/registry.py | 10 + tests/test_azure_openai_provider.py | 356 +++++++++++++++++++++ 6 files changed, 576 insertions(+), 1 deletion(-) create mode 100644 nanobot/providers/azure_openai_provider.py create mode 100644 tests/test_azure_openai_provider.py diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index aca0778..2d0f3c2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -203,6 +203,7 @@ def _make_provider(config: Config): from nanobot.providers.custom_provider import CustomProvider from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider + from nanobot.providers.azure_openai_provider import AzureOpenAIProvider model = config.agents.defaults.model provider_name = config.get_provider_name(model) @@ -220,6 +221,20 @@ def _make_provider(config: Config): default_model=model, ) + # Azure OpenAI: direct Azure OpenAI endpoint with deployment name + if provider_name == "azure_openai": + if not p or not p.api_key or not p.api_base: + console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]") + console.print("Set them in ~/.nanobot/config.json under providers.azure_openai section") + console.print("Use the model field to specify the deployment name.") + raise typer.Exit(1) + + return AzureOpenAIProvider( + api_key=p.api_key, + api_base=p.api_base, + default_model=model, + ) + 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): diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 1f2f946..44c7446 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -266,6 +266,7 @@ class ProvidersConfig(Base): """Configuration for LLM providers.""" custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint + azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name) anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) diff --git a/nanobot/providers/__init__.py b/nanobot/providers/__init__.py index b2bb2b9..5bd06f9 100644 --- a/nanobot/providers/__init__.py +++ b/nanobot/providers/__init__.py @@ -3,5 +3,6 @@ from nanobot.providers.base import LLMProvider, LLMResponse from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider +from nanobot.providers.azure_openai_provider import AzureOpenAIProvider -__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider"] +__all__ = ["LLMProvider", "LLMResponse", "LiteLLMProvider", "OpenAICodexProvider", "AzureOpenAIProvider"] diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py new file mode 100644 index 0000000..25580ac --- /dev/null +++ b/nanobot/providers/azure_openai_provider.py @@ -0,0 +1,192 @@ +"""Azure OpenAI provider implementation with API version 2024-10-21.""" + +from __future__ import annotations + +import uuid +from typing import Any +from urllib.parse import urljoin + +import httpx +import json_repair + +from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest + + +class AzureOpenAIProvider(LLMProvider): + """ + Azure OpenAI provider with API version 2024-10-21 compliance. + + Features: + - Hardcoded API version 2024-10-21 + - Uses model field as Azure deployment name in URL path + - Uses api-key header instead of Authorization Bearer + - Uses max_completion_tokens instead of max_tokens + - Direct HTTP calls, bypasses LiteLLM + """ + + def __init__( + self, + api_key: str = "", + api_base: str = "", + default_model: str = "gpt-5.2-chat", + ): + super().__init__(api_key, api_base) + self.default_model = default_model + self.api_version = "2024-10-21" + + # Validate required parameters + if not api_key: + raise ValueError("Azure OpenAI api_key is required") + if not api_base: + raise ValueError("Azure OpenAI api_base is required") + + # Ensure api_base ends with / + if not api_base.endswith('/'): + api_base += '/' + self.api_base = api_base + + def _build_chat_url(self, deployment_name: str) -> str: + """Build the Azure OpenAI chat completions URL.""" + # Azure OpenAI URL format: + # https://{resource}.openai.azure.com/openai/deployments/{deployment}/chat/completions?api-version={version} + base_url = self.api_base + if not base_url.endswith('/'): + base_url += '/' + + url = urljoin( + base_url, + f"openai/deployments/{deployment_name}/chat/completions" + ) + return f"{url}?api-version={self.api_version}" + + def _build_headers(self) -> dict[str, str]: + """Build headers for Azure OpenAI API with api-key header.""" + return { + "Content-Type": "application/json", + "api-key": self.api_key, # Azure OpenAI uses api-key header, not Authorization + "x-session-affinity": uuid.uuid4().hex, # For cache locality + } + + def _prepare_request_payload( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + ) -> dict[str, Any]: + """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" + payload: dict[str, Any] = { + "messages": self._sanitize_empty_content(messages), + "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens + "temperature": temperature, + } + + if reasoning_effort: + payload["reasoning_effort"] = reasoning_effort + + if tools: + payload["tools"] = tools + payload["tool_choice"] = "auto" + + return payload + + async def chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None = None, + model: str | None = None, + max_tokens: int = 4096, + temperature: float = 0.7, + reasoning_effort: str | None = None, + ) -> LLMResponse: + """ + Send a chat completion request to Azure OpenAI. + + Args: + messages: List of message dicts with 'role' and 'content'. + tools: Optional list of tool definitions in OpenAI format. + model: Model identifier (used as deployment name). + max_tokens: Maximum tokens in response (mapped to max_completion_tokens). + temperature: Sampling temperature. + reasoning_effort: Optional reasoning effort parameter. + + Returns: + LLMResponse with content and/or tool calls. + """ + deployment_name = model or self.default_model + url = self._build_chat_url(deployment_name) + headers = self._build_headers() + payload = self._prepare_request_payload( + messages, tools, model, max_tokens, temperature, reasoning_effort + ) + + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, headers=headers, json=payload) + if response.status_code != 200: + return LLMResponse( + content=f"Azure OpenAI API Error {response.status_code}: {response.text}", + finish_reason="error", + ) + + response_data = await response.json() + return self._parse_response(response_data) + + except Exception as e: + return LLMResponse( + content=f"Error calling Azure OpenAI: {str(e)}", + finish_reason="error", + ) + + def _parse_response(self, response: dict[str, Any]) -> LLMResponse: + """Parse Azure OpenAI response into our standard format.""" + try: + choice = response["choices"][0] + message = choice["message"] + + tool_calls = [] + if message.get("tool_calls"): + for tc in message["tool_calls"]: + # Parse arguments from JSON string if needed + args = tc["function"]["arguments"] + if isinstance(args, str): + args = json_repair.loads(args) + + tool_calls.append( + ToolCallRequest( + id=tc["id"], + name=tc["function"]["name"], + arguments=args, + ) + ) + + usage = {} + if response.get("usage"): + usage_data = response["usage"] + usage = { + "prompt_tokens": usage_data.get("prompt_tokens", 0), + "completion_tokens": usage_data.get("completion_tokens", 0), + "total_tokens": usage_data.get("total_tokens", 0), + } + + reasoning_content = message.get("reasoning_content") or None + + return LLMResponse( + content=message.get("content"), + tool_calls=tool_calls, + finish_reason=choice.get("finish_reason", "stop"), + usage=usage, + reasoning_content=reasoning_content, + ) + + except (KeyError, IndexError) as e: + return LLMResponse( + content=f"Error parsing Azure OpenAI response: {str(e)}", + finish_reason="error", + ) + + def get_default_model(self) -> str: + """Get the default model (also used as default deployment name).""" + return self.default_model \ No newline at end of file diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index df915b7..93a1138 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -81,6 +81,16 @@ PROVIDERS: tuple[ProviderSpec, ...] = ( is_direct=True, ), + # === Azure OpenAI (direct API calls with API version 2024-10-21) ===== + ProviderSpec( + name="azure_openai", + keywords=("azure", "azure-openai"), + env_key="", + display_name="Azure OpenAI", + litellm_prefix="", + is_direct=True, + ), + # === Gateways (detected by api_key / api_base, not model name) ========= # Gateways can route any model, so they win in fallback. diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py new file mode 100644 index 0000000..54338b9 --- /dev/null +++ b/tests/test_azure_openai_provider.py @@ -0,0 +1,356 @@ +"""Test Azure OpenAI provider implementation (updated for model-based deployment names).""" + +import asyncio +import pytest +from unittest.mock import AsyncMock, patch + +from nanobot.providers.azure_openai_provider import AzureOpenAIProvider +from nanobot.providers.base import LLMResponse + + +def test_azure_openai_provider_init(): + """Test AzureOpenAIProvider initialization without deployment_name.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o-deployment", + ) + + assert provider.api_key == "test-key" + assert provider.api_base == "https://test-resource.openai.azure.com/" + assert provider.default_model == "gpt-4o-deployment" + assert provider.api_version == "2024-10-21" + + +def test_azure_openai_provider_init_validation(): + """Test AzureOpenAIProvider initialization validation.""" + # Missing api_key + with pytest.raises(ValueError, match="Azure OpenAI api_key is required"): + AzureOpenAIProvider(api_key="", api_base="https://test.com") + + # Missing api_base + with pytest.raises(ValueError, match="Azure OpenAI api_base is required"): + AzureOpenAIProvider(api_key="test", api_base="") + + +def test_build_chat_url(): + """Test Azure OpenAI URL building with different deployment names.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + # Test various deployment names + test_cases = [ + ("gpt-4o-deployment", "https://test-resource.openai.azure.com/openai/deployments/gpt-4o-deployment/chat/completions?api-version=2024-10-21"), + ("gpt-35-turbo", "https://test-resource.openai.azure.com/openai/deployments/gpt-35-turbo/chat/completions?api-version=2024-10-21"), + ("custom-model", "https://test-resource.openai.azure.com/openai/deployments/custom-model/chat/completions?api-version=2024-10-21"), + ] + + for deployment_name, expected_url in test_cases: + url = provider._build_chat_url(deployment_name) + assert url == expected_url + + +def test_build_chat_url_api_base_without_slash(): + """Test URL building when api_base doesn't end with slash.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", # No trailing slash + default_model="gpt-4o", + ) + + url = provider._build_chat_url("test-deployment") + expected = "https://test-resource.openai.azure.com/openai/deployments/test-deployment/chat/completions?api-version=2024-10-21" + assert url == expected + + +def test_build_headers(): + """Test Azure OpenAI header building with api-key authentication.""" + provider = AzureOpenAIProvider( + api_key="test-api-key-123", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + headers = provider._build_headers() + assert headers["Content-Type"] == "application/json" + assert headers["api-key"] == "test-api-key-123" # Azure OpenAI specific header + assert "x-session-affinity" in headers + + +def test_prepare_request_payload(): + """Test request payload preparation with Azure OpenAI 2024-10-21 compliance.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + messages = [{"role": "user", "content": "Hello"}] + payload = provider._prepare_request_payload(messages, max_tokens=1500, temperature=0.8) + + assert payload["messages"] == messages + assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens + assert payload["temperature"] == 0.8 + assert "tools" not in payload + + # Test with tools + tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] + payload_with_tools = provider._prepare_request_payload(messages, tools=tools) + assert payload_with_tools["tools"] == tools + assert payload_with_tools["tool_choice"] == "auto" + + # Test with reasoning_effort + payload_with_reasoning = provider._prepare_request_payload(messages, reasoning_effort="medium") + assert payload_with_reasoning["reasoning_effort"] == "medium" + + +@pytest.mark.asyncio +async def test_chat_success(): + """Test successful chat request using model as deployment name.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o-deployment", + ) + + # Mock response data + mock_response_data = { + "choices": [{ + "message": { + "content": "Hello! How can I help you today?", + "role": "assistant" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 12, + "completion_tokens": 18, + "total_tokens": 30 + } + } + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + # Test with specific model (deployment name) + messages = [{"role": "user", "content": "Hello"}] + result = await provider.chat(messages, model="custom-deployment") + + assert isinstance(result, LLMResponse) + assert result.content == "Hello! How can I help you today?" + assert result.finish_reason == "stop" + assert result.usage["prompt_tokens"] == 12 + assert result.usage["completion_tokens"] == 18 + assert result.usage["total_tokens"] == 30 + + # Verify URL was built with the provided model as deployment name + call_args = mock_context.post.call_args + expected_url = "https://test-resource.openai.azure.com/openai/deployments/custom-deployment/chat/completions?api-version=2024-10-21" + assert call_args[0][0] == expected_url + + +@pytest.mark.asyncio +async def test_chat_uses_default_model_when_no_model_provided(): + """Test that chat uses default_model when no model is specified.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="default-deployment", + ) + + mock_response_data = { + "choices": [{ + "message": {"content": "Response", "role": "assistant"}, + "finish_reason": "stop" + }], + "usage": {"prompt_tokens": 5, "completion_tokens": 5, "total_tokens": 10} + } + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "Test"}] + await provider.chat(messages) # No model specified + + # Verify URL was built with default model as deployment name + call_args = mock_context.post.call_args + expected_url = "https://test-resource.openai.azure.com/openai/deployments/default-deployment/chat/completions?api-version=2024-10-21" + assert call_args[0][0] == expected_url + + +@pytest.mark.asyncio +async def test_chat_with_tool_calls(): + """Test chat request with tool calls in response.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + # Mock response with tool calls + mock_response_data = { + "choices": [{ + "message": { + "content": None, + "role": "assistant", + "tool_calls": [{ + "id": "call_12345", + "function": { + "name": "get_weather", + "arguments": '{"location": "San Francisco"}' + } + }] + }, + "finish_reason": "tool_calls" + }], + "usage": { + "prompt_tokens": 20, + "completion_tokens": 15, + "total_tokens": 35 + } + } + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.json = AsyncMock(return_value=mock_response_data) + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "What's the weather?"}] + tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] + result = await provider.chat(messages, tools=tools, model="weather-model") + + assert isinstance(result, LLMResponse) + assert result.content is None + assert result.finish_reason == "tool_calls" + assert len(result.tool_calls) == 1 + assert result.tool_calls[0].name == "get_weather" + assert result.tool_calls[0].arguments == {"location": "San Francisco"} + + +@pytest.mark.asyncio +async def test_chat_api_error(): + """Test chat request API error handling.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + with patch("httpx.AsyncClient") as mock_client: + mock_response = AsyncMock() + mock_response.status_code = 401 + mock_response.text = "Invalid authentication credentials" + + mock_context = AsyncMock() + mock_context.post = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "Hello"}] + result = await provider.chat(messages) + + assert isinstance(result, LLMResponse) + assert "Azure OpenAI API Error 401" in result.content + assert "Invalid authentication credentials" in result.content + assert result.finish_reason == "error" + + +@pytest.mark.asyncio +async def test_chat_connection_error(): + """Test chat request connection error handling.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + with patch("httpx.AsyncClient") as mock_client: + mock_context = AsyncMock() + mock_context.post = AsyncMock(side_effect=Exception("Connection failed")) + mock_client.return_value.__aenter__.return_value = mock_context + + messages = [{"role": "user", "content": "Hello"}] + result = await provider.chat(messages) + + assert isinstance(result, LLMResponse) + assert "Error calling Azure OpenAI: Connection failed" in result.content + assert result.finish_reason == "error" + + +def test_parse_response_malformed(): + """Test response parsing with malformed data.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + # Test with missing choices + malformed_response = {"usage": {"prompt_tokens": 10}} + result = provider._parse_response(malformed_response) + + assert isinstance(result, LLMResponse) + assert "Error parsing Azure OpenAI response" in result.content + assert result.finish_reason == "error" + + +def test_get_default_model(): + """Test get_default_model method.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="my-custom-deployment", + ) + + assert provider.get_default_model() == "my-custom-deployment" + + +if __name__ == "__main__": + # Run basic tests + print("Running basic Azure OpenAI provider tests...") + + # Test initialization + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o-deployment", + ) + print("โœ… Provider initialization successful") + + # Test URL building + url = provider._build_chat_url("my-deployment") + expected = "https://test-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-10-21" + assert url == expected + print("โœ… URL building works correctly") + + # Test headers + headers = provider._build_headers() + assert headers["api-key"] == "test-key" + assert headers["Content-Type"] == "application/json" + print("โœ… Header building works correctly") + + # Test payload preparation + messages = [{"role": "user", "content": "Test"}] + payload = provider._prepare_request_payload(messages, max_tokens=1000) + assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format + print("โœ… Payload preparation works correctly") + + print("โœ… All basic tests passed! Updated test file is working correctly.") \ No newline at end of file From a25923b793f00e668b66f7bdda1aa084b0d3b868 Mon Sep 17 00:00:00 2001 From: SLAR_Edge Date: Fri, 6 Mar 2026 17:10:53 +0800 Subject: [PATCH 063/113] feat: enhance message sending to include file attachments in Discord API --- nanobot/channels/discord.py | 59 +++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index c868bbf..8672327 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -75,7 +75,7 @@ class DiscordChannel(BaseChannel): self._http = None async def send(self, msg: OutboundMessage) -> None: - """Send a message through Discord REST API.""" + """Send a message through Discord REST API, including file attachments.""" if not self._http: logger.warning("Discord HTTP client not initialized") return @@ -84,6 +84,11 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} try: + # Send file attachments first + for media_path in msg.media or []: + await self._send_file(url, headers, media_path, reply_to=msg.reply_to) + + # Send text content chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) if not chunks: return @@ -91,8 +96,8 @@ class DiscordChannel(BaseChannel): for i, chunk in enumerate(chunks): payload: dict[str, Any] = {"content": chunk} - # Only set reply reference on the first chunk - if i == 0 and msg.reply_to: + # Only set reply reference on the first chunk (if no media was sent) + if i == 0 and msg.reply_to and not msg.media: payload["message_reference"] = {"message_id": msg.reply_to} payload["allowed_mentions"] = {"replied_user": False} @@ -123,6 +128,54 @@ class DiscordChannel(BaseChannel): await asyncio.sleep(1) return False + async def _send_file( + self, + url: str, + headers: dict[str, str], + file_path: str, + reply_to: str | None = None, + ) -> bool: + """Send a file attachment via Discord REST API using multipart/form-data.""" + path = Path(file_path) + if not path.is_file(): + logger.warning("Discord file not found, skipping: {}", file_path) + return False + + if path.stat().st_size > MAX_ATTACHMENT_BYTES: + logger.warning("Discord file too large (>20MB), skipping: {}", path.name) + return False + + payload_json: dict[str, Any] = {} + if reply_to: + payload_json["message_reference"] = {"message_id": reply_to} + payload_json["allowed_mentions"] = {"replied_user": False} + + for attempt in range(3): + try: + with open(path, "rb") as f: + files = {"files[0]": (path.name, f, "application/octet-stream")} + data: dict[str, Any] = {} + if payload_json: + data["payload_json"] = json.dumps(payload_json) + response = await self._http.post( + url, headers=headers, files=files, data=data + ) + if response.status_code == 429: + resp_data = response.json() + retry_after = float(resp_data.get("retry_after", 1.0)) + logger.warning("Discord rate limited, retrying in {}s", retry_after) + await asyncio.sleep(retry_after) + continue + response.raise_for_status() + logger.info("Discord file sent: {}", path.name) + return True + except Exception as e: + if attempt == 2: + logger.error("Error sending Discord file {}: {}", path.name, e) + else: + await asyncio.sleep(1) + return False + async def _gateway_loop(self) -> None: """Main gateway loop: identify, heartbeat, dispatch events.""" if not self._ws: From 52e725053c149745c62a3896339b6c81a193df2c Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 09:20:47 +0000 Subject: [PATCH 064/113] Always use temperature 1 --- nanobot/providers/azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 25580ac..c849fd9 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -80,7 +80,7 @@ class AzureOpenAIProvider(LLMProvider): payload: dict[str, Any] = { "messages": self._sanitize_empty_content(messages), "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens - "temperature": temperature, + "temperature": 1, } if reasoning_effort: From 7684f5b9029cc38e18069491ee692e09687de8c2 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 09:49:26 +0000 Subject: [PATCH 065/113] Fix the temperature issue, remove temperature --- nanobot/providers/azure_openai_provider.py | 7 ++----- tests/test_azure_openai_provider.py | 12 ++++++------ 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index c849fd9..52efde6 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -71,16 +71,13 @@ class AzureOpenAIProvider(LLMProvider): self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, - model: str | None = None, max_tokens: int = 4096, - temperature: float = 0.7, reasoning_effort: str | None = None, ) -> dict[str, Any]: """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" payload: dict[str, Any] = { "messages": self._sanitize_empty_content(messages), "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens - "temperature": 1, } if reasoning_effort: @@ -119,7 +116,7 @@ class AzureOpenAIProvider(LLMProvider): url = self._build_chat_url(deployment_name) headers = self._build_headers() payload = self._prepare_request_payload( - messages, tools, model, max_tokens, temperature, reasoning_effort + messages, tools, max_tokens, reasoning_effort ) try: @@ -131,7 +128,7 @@ class AzureOpenAIProvider(LLMProvider): finish_reason="error", ) - response_data = await response.json() + response_data = response.json() return self._parse_response(response_data) except Exception as e: diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py index 54338b9..df2cdc3 100644 --- a/tests/test_azure_openai_provider.py +++ b/tests/test_azure_openai_provider.py @@ -2,7 +2,7 @@ import asyncio import pytest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.base import LLMResponse @@ -89,11 +89,11 @@ def test_prepare_request_payload(): ) messages = [{"role": "user", "content": "Hello"}] - payload = provider._prepare_request_payload(messages, max_tokens=1500, temperature=0.8) + payload = provider._prepare_request_payload(messages, max_tokens=1500) assert payload["messages"] == messages assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens - assert payload["temperature"] == 0.8 + assert "temperature" not in payload # Temperature not included in payload assert "tools" not in payload # Test with tools @@ -135,7 +135,7 @@ async def test_chat_success(): with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.status_code = 200 - mock_response.json = AsyncMock(return_value=mock_response_data) + mock_response.json = Mock(return_value=mock_response_data) mock_context = AsyncMock() mock_context.post = AsyncMock(return_value=mock_response) @@ -178,7 +178,7 @@ async def test_chat_uses_default_model_when_no_model_provided(): with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.status_code = 200 - mock_response.json = AsyncMock(return_value=mock_response_data) + mock_response.json = Mock(return_value=mock_response_data) mock_context = AsyncMock() mock_context.post = AsyncMock(return_value=mock_response) @@ -228,7 +228,7 @@ async def test_chat_with_tool_calls(): with patch("httpx.AsyncClient") as mock_client: mock_response = AsyncMock() mock_response.status_code = 200 - mock_response.json = AsyncMock(return_value=mock_response_data) + mock_response.json = Mock(return_value=mock_response_data) mock_context = AsyncMock() mock_context.post = AsyncMock(return_value=mock_response) From 858b136f30a6206ac8f005dfebcd9ae142e64f47 Mon Sep 17 00:00:00 2001 From: samsonchoi Date: Fri, 6 Mar 2026 17:57:21 +0800 Subject: [PATCH 066/113] docs: add comprehensive multi-instance configuration guide - Add detailed setup examples with directory structure - Document complete isolation mechanism (config, workspace, cron, logs, media) - Include use cases and production deployment patterns - Add management scripts for systemd (Linux) and launchd (macOS) - Provide step-by-step configuration examples --- README.md | 224 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 209 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index fc0a1fb..685817f 100644 --- a/README.md +++ b/README.md @@ -892,30 +892,224 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ## Multiple Instances -Run multiple nanobot instances simultaneously, each with its own workspace and configuration. +Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage. + +### Quick Start ```bash # Instance A - Telegram bot -nanobot gateway -w ~/.nanobot/botA -p 18791 +nanobot gateway --config ~/.nanobot-telegram/config.json -# Instance B - Discord bot -nanobot gateway -w ~/.nanobot/botB -p 18792 +# Instance B - Discord bot +nanobot gateway --config ~/.nanobot-discord/config.json -# Instance C - Using custom config file -nanobot gateway -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json -p 18793 +# Instance C - Feishu bot with custom port +nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792 ``` -| Option | Short | Description | -|--------|-------|-------------| -| `--workspace` | `-w` | Workspace directory (default: `~/.nanobot/workspace`) | -| `--config` | `-c` | Config file path (default: `~/.nanobot/config.json`) | -| `--port` | `-p` | Gateway port (default: `18790`) | +### Complete Isolation -Each instance has its own: -- Workspace directory (MEMORY.md, HEARTBEAT.md, session files) -- Cron jobs storage (`workspace/cron/jobs.json`) -- Configuration (if using `--config`) +When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation: +| Component | Isolation | Example | +|-----------|-----------|---------| +| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` | +| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` | +| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` | +| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` | +| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` | + +### Setup Example + +**1. Create directory structure for each instance:** + +```bash +# Instance A +mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media} +cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json + +# Instance B +mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media} +cp ~/.nanobot/config.json ~/.nanobot-discord/config.json +``` + +**2. Configure each instance:** + +Edit `~/.nanobot-telegram/config.json`: +```json +{ + "agents": { + "defaults": { + "workspace": "~/.nanobot-telegram/workspace", + "model": "anthropic/claude-sonnet-4-6" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_TELEGRAM_BOT_TOKEN" + } + }, + "gateway": { + "port": 18790 + } +} +``` + +Edit `~/.nanobot-discord/config.json`: +```json +{ + "agents": { + "defaults": { + "workspace": "~/.nanobot-discord/workspace", + "model": "anthropic/claude-opus-4" + } + }, + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_DISCORD_BOT_TOKEN" + } + }, + "gateway": { + "port": 18791 + } +} +``` + +**3. Start instances:** + +```bash +# Terminal 1 +nanobot gateway --config ~/.nanobot-telegram/config.json + +# Terminal 2 +nanobot gateway --config ~/.nanobot-discord/config.json +``` + +### Use Cases + +- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc. +- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously +- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent) +- **Multi-Tenant**: Serve multiple users/teams with isolated configurations + +### Management Scripts + +For production deployments, create management scripts for each instance: + +```bash +#!/bin/bash +# manage-telegram.sh + +INSTANCE_NAME="telegram" +CONFIG_FILE="$HOME/.nanobot-telegram/config.json" +LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log" + +case "$1" in + start) + nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 & + echo "Started $INSTANCE_NAME instance (PID: $!)" + ;; + stop) + pkill -f "nanobot gateway.*$CONFIG_FILE" + echo "Stopped $INSTANCE_NAME instance" + ;; + restart) + $0 stop + sleep 2 + $0 start + ;; + status) + pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null + if [ $? -eq 0 ]; then + echo "$INSTANCE_NAME instance is running" + else + echo "$INSTANCE_NAME instance is not running" + fi + ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +``` + +### systemd Service (Linux) + +For automatic startup and crash recovery, create a systemd service for each instance: + +```ini +# ~/.config/systemd/user/nanobot-telegram.service +[Unit] +Description=Nanobot Telegram Instance +After=network.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target +``` + +Enable and start: +```bash +systemctl --user daemon-reload +systemctl --user enable --now nanobot-telegram +systemctl --user enable --now nanobot-discord +``` + +### launchd Service (macOS) + +Create a plist file for each instance: + +```xml + + + + + + Label + com.nanobot.telegram + + ProgramArguments + + /path/to/nanobot + gateway + --config + /Users/yourname/.nanobot-telegram/config.json + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /Users/yourname/.nanobot-telegram/logs/stdout.log + + StandardErrorPath + /Users/yourname/.nanobot-telegram/logs/stderr.log + + +``` + +Load the service: +```bash +launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist +launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist +``` + +### Notes + +- Each instance must use a different port (default: 18790) +- Instances are completely independent โ€” no shared state or cross-talk +- You can run different LLM models, providers, and channel configurations per instance +- Memory, sessions, and cron jobs are fully isolated between instances ## CLI Reference From 0b0f47f09fd37f1c354ba5a656b8f756d72a3382 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 10:37:16 +0000 Subject: [PATCH 067/113] Update readme with azure openai support --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fc0a1fb..61f38f4 100644 --- a/README.md +++ b/README.md @@ -670,6 +670,7 @@ Config file: `~/.nanobot/config.json` | `custom` | Any OpenAI-compatible endpoint (direct, no LiteLLM) | โ€” | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | +| `azure_openai` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | From a8ce0a3084be719d1b21726e6560bc64a26deff1 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 16:05:43 +0000 Subject: [PATCH 068/113] Adding some more insights for failure in Azure OpenAI calls --- nanobot/providers/azure_openai_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 52efde6..fc8e950 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -133,7 +133,7 @@ class AzureOpenAIProvider(LLMProvider): except Exception as e: return LLMResponse( - content=f"Error calling Azure OpenAI: {str(e)}", + content=f"Error calling Azure OpenAI: {repr(e)}", finish_reason="error", ) From 0409d725798893b6299677c317fba32e858ed56c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 6 Mar 2026 16:19:19 +0000 Subject: [PATCH 069/113] feat(telegram): improve streaming UX and add table rendering --- nanobot/channels/telegram.py | 138 ++++++++++++++++++++++++++--------- 1 file changed, 104 insertions(+), 34 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 9097496..aaa24e7 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,6 +4,8 @@ from __future__ import annotations import asyncio import re +import time +import unicodedata from loguru import logger from telegram import BotCommand, ReplyParameters, Update @@ -19,6 +21,47 @@ from nanobot.utils.helpers import split_message TELEGRAM_MAX_MESSAGE_LEN = 4000 # Telegram message character limit +def _strip_md(s: str) -> str: + """Strip markdown inline formatting from text.""" + s = re.sub(r'\*\*(.+?)\*\*', r'\1', s) + s = re.sub(r'__(.+?)__', r'\1', s) + s = re.sub(r'~~(.+?)~~', r'\1', s) + s = re.sub(r'`([^`]+)`', r'\1', s) + return s.strip() + + +def _render_table_box(table_lines: list[str]) -> str: + """Convert markdown pipe-table to compact aligned text for
 display."""
+
+    def dw(s: str) -> int:
+        return sum(2 if unicodedata.east_asian_width(c) in ('W', 'F') else 1 for c in s)
+
+    rows: list[list[str]] = []
+    has_sep = False
+    for line in table_lines:
+        cells = [_strip_md(c) for c in line.strip().strip('|').split('|')]
+        if all(re.match(r'^:?-+:?$', c) for c in cells if c):
+            has_sep = True
+            continue
+        rows.append(cells)
+    if not rows or not has_sep:
+        return '\n'.join(table_lines)
+
+    ncols = max(len(r) for r in rows)
+    for r in rows:
+        r.extend([''] * (ncols - len(r)))
+    widths = [max(dw(r[c]) for r in rows) for c in range(ncols)]
+
+    def dr(cells: list[str]) -> str:
+        return '  '.join(f'{c}{" " * (w - dw(c))}' for c, w in zip(cells, widths))
+
+    out = [dr(rows[0])]
+    out.append('  '.join('โ”€' * w for w in widths))
+    for row in rows[1:]:
+        out.append(dr(row))
+    return '\n'.join(out)
+
+
 def _markdown_to_telegram_html(text: str) -> str:
     """
     Convert markdown to Telegram-safe HTML.
@@ -34,6 +77,27 @@ def _markdown_to_telegram_html(text: str) -> str:
 
     text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text)
 
+    # 1.5. Convert markdown tables to box-drawing (reuse code_block placeholders)
+    lines = text.split('\n')
+    rebuilt: list[str] = []
+    li = 0
+    while li < len(lines):
+        if re.match(r'^\s*\|.+\|', lines[li]):
+            tbl: list[str] = []
+            while li < len(lines) and re.match(r'^\s*\|.+\|', lines[li]):
+                tbl.append(lines[li])
+                li += 1
+            box = _render_table_box(tbl)
+            if box != '\n'.join(tbl):
+                code_blocks.append(box)
+                rebuilt.append(f"\x00CB{len(code_blocks) - 1}\x00")
+            else:
+                rebuilt.extend(tbl)
+        else:
+            rebuilt.append(lines[li])
+            li += 1
+    text = '\n'.join(rebuilt)
+
     # 2. Extract and protect inline code
     inline_codes: list[str] = []
     def save_inline_code(m: re.Match) -> str:
@@ -255,42 +319,48 @@ class TelegramChannel(BaseChannel):
         # Send text content
         if msg.content and msg.content != "[empty message]":
             is_progress = msg.metadata.get("_progress", False)
-            draft_id = msg.metadata.get("message_id")
 
             for chunk in split_message(msg.content, TELEGRAM_MAX_MESSAGE_LEN):
-                try:
-                    html = _markdown_to_telegram_html(chunk)
-                    if is_progress and draft_id:
-                        await self._app.bot.send_message_draft(
-                            chat_id=chat_id,
-                            draft_id=draft_id,
-                            text=html,
-                            parse_mode="HTML"
-                        )
-                    else:
-                        await self._app.bot.send_message(
-                            chat_id=chat_id,
-                            text=html,
-                            parse_mode="HTML",
-                            reply_parameters=reply_params
-                        )
-                except Exception as e:
-                    logger.warning("HTML parse failed, falling back to plain text: {}", e)
-                    try:
-                        if is_progress and draft_id:
-                            await self._app.bot.send_message_draft(
-                                chat_id=chat_id,
-                                draft_id=draft_id,
-                                text=chunk
-                            )
-                        else:
-                            await self._app.bot.send_message(
-                                chat_id=chat_id,
-                                text=chunk,
-                                reply_parameters=reply_params
-                            )
-                    except Exception as e2:
-                        logger.error("Error sending Telegram message: {}", e2)
+                # Final response: simulate streaming via draft, then persist
+                if not is_progress:
+                    await self._send_with_streaming(chat_id, chunk, reply_params)
+                else:
+                    await self._send_text(chat_id, chunk, reply_params)
+
+    async def _send_text(self, chat_id: int, text: str, reply_params=None) -> None:
+        """Send a plain text message with HTML fallback."""
+        try:
+            html = _markdown_to_telegram_html(text)
+            await self._app.bot.send_message(
+                chat_id=chat_id, text=html, parse_mode="HTML",
+                reply_parameters=reply_params,
+            )
+        except Exception as e:
+            logger.warning("HTML parse failed, falling back to plain text: {}", e)
+            try:
+                await self._app.bot.send_message(
+                    chat_id=chat_id, text=text, reply_parameters=reply_params,
+                )
+            except Exception as e2:
+                logger.error("Error sending Telegram message: {}", e2)
+
+    async def _send_with_streaming(self, chat_id: int, text: str, reply_params=None) -> None:
+        """Simulate streaming via send_message_draft, then persist with send_message."""
+        draft_id = int(time.time() * 1000) % (2**31)
+        try:
+            step = max(len(text) // 8, 40)
+            for i in range(step, len(text), step):
+                await self._app.bot.send_message_draft(
+                    chat_id=chat_id, draft_id=draft_id, text=text[:i],
+                )
+                await asyncio.sleep(0.04)
+            await self._app.bot.send_message_draft(
+                chat_id=chat_id, draft_id=draft_id, text=text,
+            )
+            await asyncio.sleep(0.15)
+        except Exception:
+            pass
+        await self._send_text(chat_id, text, reply_params)
 
     async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
         """Handle /start command."""

From 43022b17184070ce6b1a4fe487b27517238050d7 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar 
Date: Fri, 6 Mar 2026 17:20:52 +0000
Subject: [PATCH 070/113] Fix unit test after updating error message

---
 tests/test_azure_openai_provider.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py
index df2cdc3..680ddf4 100644
--- a/tests/test_azure_openai_provider.py
+++ b/tests/test_azure_openai_provider.py
@@ -291,7 +291,7 @@ async def test_chat_connection_error():
         result = await provider.chat(messages)
         
         assert isinstance(result, LLMResponse)
-        assert "Error calling Azure OpenAI: Connection failed" in result.content
+        assert "Error calling Azure OpenAI: Exception('Connection failed')" in result.content
         assert result.finish_reason == "error"
 
 

From 7e4594e08dc74ab438d3d903a1fac6441a498615 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar 
Date: Fri, 6 Mar 2026 18:12:46 +0000
Subject: [PATCH 071/113] Increase timeout for chat completion calls

---
 nanobot/providers/azure_openai_provider.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index fc8e950..6da37e7 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider):
         )
 
         try:
-            async with httpx.AsyncClient() as client:
+            async with httpx.AsyncClient(timeout=60.0) as client:
                 response = await client.post(url, headers=headers, json=payload)
                 if response.status_code != 200:
                     return LLMResponse(

From 73be53d4bd7e5ff7363644248ab47296959bd3c9 Mon Sep 17 00:00:00 2001
From: Kunal Karmakar 
Date: Fri, 6 Mar 2026 18:16:15 +0000
Subject: [PATCH 072/113] Add SSL verification

---
 nanobot/providers/azure_openai_provider.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py
index 6da37e7..3f325aa 100644
--- a/nanobot/providers/azure_openai_provider.py
+++ b/nanobot/providers/azure_openai_provider.py
@@ -120,7 +120,7 @@ class AzureOpenAIProvider(LLMProvider):
         )
 
         try:
-            async with httpx.AsyncClient(timeout=60.0) as client:
+            async with httpx.AsyncClient(timeout=60.0, verify=True) as client:
                 response = await client.post(url, headers=headers, json=payload)
                 if response.status_code != 200:
                     return LLMResponse(

From 79f3ca4f12ffe6497f30958f4959e579b5d4434b Mon Sep 17 00:00:00 2001
From: Maciej Wojcik 
Date: Fri, 6 Mar 2026 20:32:10 +0000
Subject: [PATCH 073/113] feat(cli): add workspace and config flags to agent

---
 README.md               | 14 ++++++
 nanobot/cli/commands.py | 24 ++++++----
 tests/test_commands.py  | 97 ++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 125 insertions(+), 10 deletions(-)

diff --git a/README.md b/README.md
index 0c49608..86869a2 100644
--- a/README.md
+++ b/README.md
@@ -710,6 +710,9 @@ nanobot provider login openai-codex
 **3. Chat:**
 ```bash
 nanobot agent -m "Hello!"
+
+# Target a specific workspace/config locally
+nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!"
 ```
 
 > Docker users: use `docker run -it` for interactive OAuth login.
@@ -917,6 +920,15 @@ Each instance has its own:
 - Cron jobs storage (`workspace/cron/jobs.json`)
 - Configuration (if using `--config`)
 
+To open a CLI session against one of these instances locally:
+
+```bash
+nanobot agent -w ~/.nanobot/botA -m "Hello from botA"
+nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json
+```
+
+> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
+
 
 ## CLI Reference
 
@@ -924,6 +936,8 @@ Each instance has its own:
 |---------|-------------|
 | `nanobot onboard` | Initialize config & workspace |
 | `nanobot agent -m "..."` | Chat with the agent |
+| `nanobot agent -w ` | Chat against a specific workspace |
+| `nanobot agent -w  -c ` | Chat against a specific workspace/config |
 | `nanobot agent` | Interactive chat mode |
 | `nanobot agent --no-markdown` | Show plain-text replies |
 | `nanobot agent --logs` | Show runtime logs during chat |
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index 7d2c161..5987796 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -9,7 +9,6 @@ from pathlib import Path
 
 # Force UTF-8 encoding for Windows console
 if sys.platform == "win32":
-    import locale
     if sys.stdout.encoding != "utf-8":
         os.environ["PYTHONIOENCODING"] = "utf-8"
         # Re-open stdout/stderr with UTF-8 encoding
@@ -248,6 +247,17 @@ def _make_provider(config: Config):
     )
 
 
+def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config:
+    """Load config and optionally override the active workspace."""
+    from nanobot.config.loader import load_config
+
+    config_path = Path(config) if config else None
+    loaded = load_config(config_path)
+    if workspace:
+        loaded.agents.defaults.workspace = workspace
+    return loaded
+
+
 # ============================================================================
 # Gateway / Server
 # ============================================================================
@@ -264,7 +274,6 @@ def gateway(
     from nanobot.agent.loop import AgentLoop
     from nanobot.bus.queue import MessageBus
     from nanobot.channels.manager import ChannelManager
-    from nanobot.config.loader import load_config
     from nanobot.cron.service import CronService
     from nanobot.cron.types import CronJob
     from nanobot.heartbeat.service import HeartbeatService
@@ -274,10 +283,7 @@ def gateway(
         import logging
         logging.basicConfig(level=logging.DEBUG)
 
-    config_path = Path(config) if config else None
-    config = load_config(config_path)
-    if workspace:
-        config.agents.defaults.workspace = workspace
+    config = _load_runtime_config(config, workspace)
 
     console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
     sync_workspace_templates(config.workspace_path)
@@ -448,6 +454,8 @@ def gateway(
 def agent(
     message: str = typer.Option(None, "--message", "-m", help="Message to send to the agent"),
     session_id: str = typer.Option("cli:direct", "--session", "-s", help="Session ID"),
+    workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
+    config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
     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"),
 ):
@@ -456,10 +464,10 @@ def agent(
 
     from nanobot.agent.loop import AgentLoop
     from nanobot.bus.queue import MessageBus
-    from nanobot.config.loader import get_data_dir, load_config
+    from nanobot.config.loader import get_data_dir
     from nanobot.cron.service import CronService
 
-    config = load_config()
+    config = _load_runtime_config(config, workspace)
     sync_workspace_templates(config.workspace_path)
 
     bus = MessageBus()
diff --git a/tests/test_commands.py b/tests/test_commands.py
index 044d113..46ee7d0 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -1,6 +1,6 @@
 import shutil
 from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import AsyncMock, MagicMock, patch
 
 import pytest
 from typer.testing import CliRunner
@@ -19,7 +19,7 @@ def mock_paths():
     """Mock config/workspace paths for test isolation."""
     with patch("nanobot.config.loader.get_config_path") as mock_cp, \
          patch("nanobot.config.loader.save_config") as mock_sc, \
-         patch("nanobot.config.loader.load_config") as mock_lc, \
+         patch("nanobot.config.loader.load_config"), \
          patch("nanobot.utils.helpers.get_workspace_path") as mock_ws:
 
         base_dir = Path("./test_onboard_data")
@@ -128,3 +128,96 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix():
 def test_openai_codex_strip_prefix_supports_hyphen_and_underscore():
     assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex"
     assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex"
+
+
+@pytest.fixture
+def mock_agent_runtime(tmp_path):
+    """Mock agent command dependencies for focused CLI tests."""
+    config = Config()
+    config.agents.defaults.workspace = str(tmp_path / "default-workspace")
+    data_dir = tmp_path / "data"
+
+    with patch("nanobot.config.loader.load_config", return_value=config) as mock_load_config, \
+         patch("nanobot.config.loader.get_data_dir", return_value=data_dir), \
+         patch("nanobot.cli.commands.sync_workspace_templates") as mock_sync_templates, \
+         patch("nanobot.cli.commands._make_provider", return_value=object()), \
+         patch("nanobot.cli.commands._print_agent_response") as mock_print_response, \
+         patch("nanobot.bus.queue.MessageBus"), \
+         patch("nanobot.cron.service.CronService"), \
+         patch("nanobot.agent.loop.AgentLoop") as mock_agent_loop_cls:
+
+        agent_loop = MagicMock()
+        agent_loop.channels_config = None
+        agent_loop.process_direct = AsyncMock(return_value="mock-response")
+        agent_loop.close_mcp = AsyncMock(return_value=None)
+        mock_agent_loop_cls.return_value = agent_loop
+
+        yield {
+            "config": config,
+            "load_config": mock_load_config,
+            "sync_templates": mock_sync_templates,
+            "agent_loop_cls": mock_agent_loop_cls,
+            "agent_loop": agent_loop,
+            "print_response": mock_print_response,
+        }
+
+
+def test_agent_help_shows_workspace_and_config_options():
+    result = runner.invoke(app, ["agent", "--help"])
+
+    assert result.exit_code == 0
+    assert "--workspace" in result.stdout
+    assert "-w" in result.stdout
+    assert "--config" in result.stdout
+    assert "-c" in result.stdout
+
+
+def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_runtime):
+    result = runner.invoke(app, ["agent", "-m", "hello"])
+
+    assert result.exit_code == 0
+    assert mock_agent_runtime["load_config"].call_args.args == (None,)
+    assert mock_agent_runtime["sync_templates"].call_args.args == (
+        mock_agent_runtime["config"].workspace_path,
+    )
+    assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == (
+        mock_agent_runtime["config"].workspace_path
+    )
+    mock_agent_runtime["agent_loop"].process_direct.assert_awaited_once()
+    mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True)
+
+
+def test_agent_uses_explicit_config_path(mock_agent_runtime):
+    config_path = Path("/tmp/agent-config.json")
+
+    result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)])
+
+    assert result.exit_code == 0
+    assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+
+
+def test_agent_overrides_workspace_path(mock_agent_runtime):
+    workspace_path = Path("/tmp/agent-workspace")
+
+    result = runner.invoke(app, ["agent", "-m", "hello", "-w", str(workspace_path)])
+
+    assert result.exit_code == 0
+    assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
+    assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
+    assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path
+
+
+def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime):
+    config_path = Path("/tmp/agent-config.json")
+    workspace_path = Path("/tmp/agent-workspace")
+
+    result = runner.invoke(
+        app,
+        ["agent", "-m", "hello", "-c", str(config_path), "-w", str(workspace_path)],
+    )
+
+    assert result.exit_code == 0
+    assert mock_agent_runtime["load_config"].call_args.args == (config_path,)
+    assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path)
+    assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,)
+    assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path

From fdd161d7b2f65d1564b23c445eaef56f85665ce3 Mon Sep 17 00:00:00 2001
From: fat-operator 
Date: Fri, 6 Mar 2026 23:36:54 +0000
Subject: [PATCH 074/113] Implemented image support for whatsapp

---
 bridge/src/server.ts         | 13 ++++++-
 bridge/src/whatsapp.ts       | 75 ++++++++++++++++++++++++++++++------
 nanobot/channels/whatsapp.py | 34 +++++++++++++---
 3 files changed, 102 insertions(+), 20 deletions(-)

diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index 7d48f5e..ec5573a 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,6 +12,13 @@ interface SendCommand {
   text: string;
 }
 
+interface SendImageCommand {
+  type: 'send_image';
+  to: string;
+  imagePath: string;
+  caption?: string;
+}
+
 interface BridgeMessage {
   type: 'message' | 'status' | 'qr' | 'error';
   [key: string]: unknown;
@@ -72,7 +79,7 @@ export class BridgeServer {
 
     ws.on('message', async (data) => {
       try {
-        const cmd = JSON.parse(data.toString()) as SendCommand;
+        const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand;
         await this.handleCommand(cmd);
         ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
       } catch (error) {
@@ -92,9 +99,11 @@ export class BridgeServer {
     });
   }
 
-  private async handleCommand(cmd: SendCommand): Promise {
+  private async handleCommand(cmd: SendCommand | SendImageCommand): Promise {
     if (cmd.type === 'send' && this.wa) {
       await this.wa.sendMessage(cmd.to, cmd.text);
+    } else if (cmd.type === 'send_image' && this.wa) {
+      await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption);
     }
   }
 
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 069d72b..d34100f 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -9,11 +9,17 @@ import makeWASocket, {
   useMultiFileAuthState,
   fetchLatestBaileysVersion,
   makeCacheableSignalKeyStore,
+  downloadMediaMessage,
+  extractMessageContent as baileysExtractMessageContent,
 } from '@whiskeysockets/baileys';
 
 import { Boom } from '@hapi/boom';
 import qrcode from 'qrcode-terminal';
 import pino from 'pino';
+import { writeFile, mkdir, readFile } from 'fs/promises';
+import { join } from 'path';
+import { homedir } from 'os';
+import { randomBytes } from 'crypto';
 
 const VERSION = '0.1.0';
 
@@ -24,6 +30,7 @@ export interface InboundMessage {
   content: string;
   timestamp: number;
   isGroup: boolean;
+  media?: string[];
 }
 
 export interface WhatsAppClientOptions {
@@ -110,14 +117,21 @@ export class WhatsAppClient {
       if (type !== 'notify') return;
 
       for (const msg of messages) {
-        // Skip own messages
         if (msg.key.fromMe) continue;
-
-        // Skip status updates
         if (msg.key.remoteJid === 'status@broadcast') continue;
 
-        const content = this.extractMessageContent(msg);
-        if (!content) continue;
+        const unwrapped = baileysExtractMessageContent(msg.message);
+        if (!unwrapped) continue;
+
+        const content = this.getTextContent(unwrapped);
+        const mediaPaths: string[] = [];
+
+        if (unwrapped.imageMessage) {
+          const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined);
+          if (path) mediaPaths.push(path);
+        }
+
+        if (!content && mediaPaths.length === 0) continue;
 
         const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
 
@@ -125,18 +139,43 @@ export class WhatsAppClient {
           id: msg.key.id || '',
           sender: msg.key.remoteJid || '',
           pn: msg.key.remoteJidAlt || '',
-          content,
+          content: content || '',
           timestamp: msg.messageTimestamp as number,
           isGroup,
+          ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
         });
       }
     });
   }
 
-  private extractMessageContent(msg: any): string | null {
-    const message = msg.message;
-    if (!message) return null;
+  private async downloadImage(msg: any, mimetype?: string): Promise {
+    try {
+      const mediaDir = join(homedir(), '.nanobot', 'media');
+      await mkdir(mediaDir, { recursive: true });
 
+      const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
+
+      const mime = mimetype || 'image/jpeg';
+      const extMap: Record = {
+        'image/jpeg': '.jpg',
+        'image/png': '.png',
+        'image/gif': '.gif',
+        'image/webp': '.webp',
+      };
+      const ext = extMap[mime] || '.jpg';
+
+      const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
+      const filepath = join(mediaDir, filename);
+      await writeFile(filepath, buffer);
+
+      return filepath;
+    } catch (err) {
+      console.error('Failed to download image:', err);
+      return null;
+    }
+  }
+
+  private getTextContent(message: any): string | null {
     // Text message
     if (message.conversation) {
       return message.conversation;
@@ -147,9 +186,9 @@ export class WhatsAppClient {
       return message.extendedTextMessage.text;
     }
 
-    // Image with caption
-    if (message.imageMessage?.caption) {
-      return `[Image] ${message.imageMessage.caption}`;
+    // Image with optional caption
+    if (message.imageMessage) {
+      return message.imageMessage.caption || '';
     }
 
     // Video with caption
@@ -178,6 +217,18 @@ export class WhatsAppClient {
     await this.sock.sendMessage(to, { text });
   }
 
+  async sendImage(to: string, imagePath: string, caption?: string): Promise {
+    if (!this.sock) {
+      throw new Error('Not connected');
+    }
+
+    const buffer = await readFile(imagePath);
+    await this.sock.sendMessage(to, {
+      image: buffer,
+      caption: caption || undefined,
+    });
+  }
+
   async disconnect(): Promise {
     if (this.sock) {
       this.sock.end(undefined);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 0d1ec7e..1a96753 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -83,12 +83,26 @@ class WhatsAppChannel(BaseChannel):
             return
 
         try:
-            payload = {
-                "type": "send",
-                "to": msg.chat_id,
-                "text": msg.content
-            }
-            await self._ws.send(json.dumps(payload, ensure_ascii=False))
+            # Send media files first
+            for media_path in (msg.media or []):
+                try:
+                    payload = {
+                        "type": "send_image",
+                        "to": msg.chat_id,
+                        "imagePath": media_path,
+                    }
+                    await self._ws.send(json.dumps(payload, ensure_ascii=False))
+                except Exception as e:
+                    logger.error("Error sending WhatsApp media {}: {}", media_path, e)
+
+            # Send text message if there's content
+            if msg.content:
+                payload = {
+                    "type": "send",
+                    "to": msg.chat_id,
+                    "text": msg.content
+                }
+                await self._ws.send(json.dumps(payload, ensure_ascii=False))
         except Exception as e:
             logger.error("Error sending WhatsApp message: {}", e)
 
@@ -128,10 +142,18 @@ class WhatsAppChannel(BaseChannel):
                 logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
                 content = "[Voice Message: Transcription not available for WhatsApp yet]"
 
+            # Extract media paths (images downloaded by the bridge)
+            media_paths = data.get("media") or []
+
+            # For image messages without caption, provide descriptive content
+            if not content and media_paths:
+                content = "[image]"
+
             await self._handle_message(
                 sender_id=sender_id,
                 chat_id=sender,  # Use full LID for replies
                 content=content,
+                media=media_paths,
                 metadata={
                     "message_id": message_id,
                     "timestamp": data.get("timestamp"),

From 8c2589753292936212593b463168c983cf573a14 Mon Sep 17 00:00:00 2001
From: fat-operator 
Date: Fri, 6 Mar 2026 23:48:54 +0000
Subject: [PATCH 075/113] Remove image sending capabilities - cant be tested

---
 bridge/package-lock.json     | 1362 ++++++++++++++++++++++++++++++++++
 bridge/src/server.ts         |   13 +-
 bridge/src/whatsapp.ts       |   14 +-
 nanobot/channels/whatsapp.py |   26 +-
 4 files changed, 1371 insertions(+), 44 deletions(-)
 create mode 100644 bridge/package-lock.json

diff --git a/bridge/package-lock.json b/bridge/package-lock.json
new file mode 100644
index 0000000..7847d20
--- /dev/null
+++ b/bridge/package-lock.json
@@ -0,0 +1,1362 @@
+{
+  "name": "nanobot-whatsapp-bridge",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "nanobot-whatsapp-bridge",
+      "version": "0.1.0",
+      "dependencies": {
+        "@whiskeysockets/baileys": "7.0.0-rc.9",
+        "pino": "^9.0.0",
+        "qrcode-terminal": "^0.12.0",
+        "ws": "^8.17.1"
+      },
+      "devDependencies": {
+        "@types/node": "^20.14.0",
+        "@types/ws": "^8.5.10",
+        "typescript": "^5.4.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      }
+    },
+    "node_modules/@borewit/text-codec": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
+      "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
+    "node_modules/@cacheable/memory": {
+      "version": "2.0.8",
+      "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
+      "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
+      "license": "MIT",
+      "dependencies": {
+        "@cacheable/utils": "^2.4.0",
+        "@keyv/bigmap": "^1.3.1",
+        "hookified": "^1.15.1",
+        "keyv": "^5.6.0"
+      }
+    },
+    "node_modules/@cacheable/node-cache": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
+      "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
+      "license": "MIT",
+      "dependencies": {
+        "cacheable": "^2.3.1",
+        "hookified": "^1.14.0",
+        "keyv": "^5.5.5"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@cacheable/utils": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
+      "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
+      "license": "MIT",
+      "dependencies": {
+        "hashery": "^1.5.0",
+        "keyv": "^5.6.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
+      "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
+      "license": "MIT",
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@hapi/boom": {
+      "version": "9.1.4",
+      "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
+      "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@hapi/hoek": "9.x.x"
+      }
+    },
+    "node_modules/@hapi/hoek": {
+      "version": "9.3.0",
+      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
+      "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "peer": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "peer": true,
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@keyv/bigmap": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+      "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+      "license": "MIT",
+      "dependencies": {
+        "hashery": "^1.4.0",
+        "hookified": "^1.15.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "keyv": "^5.6.0"
+      }
+    },
+    "node_modules/@keyv/serialize": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+      "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+      "license": "MIT"
+    },
+    "node_modules/@pinojs/redact": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
+      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
+      "license": "MIT"
+    },
+    "node_modules/@protobufjs/aspromise": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
+      "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/base64": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
+      "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/codegen": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
+      "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/eventemitter": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
+      "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/fetch": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
+      "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@protobufjs/aspromise": "^1.1.1",
+        "@protobufjs/inquire": "^1.1.0"
+      }
+    },
+    "node_modules/@protobufjs/float": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
+      "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/inquire": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
+      "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/path": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
+      "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/pool": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
+      "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@protobufjs/utf8": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
+      "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/@tokenizer/inflate": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
+      "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "^4.4.3",
+        "token-types": "^6.1.1"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
+    "node_modules/@tokenizer/token": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
+      "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
+      "license": "MIT"
+    },
+    "node_modules/@types/long": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
+      "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.37",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
+      "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/ws": {
+      "version": "8.18.1",
+      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+      "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@whiskeysockets/baileys": {
+      "version": "7.0.0-rc.9",
+      "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
+      "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cacheable/node-cache": "^1.4.0",
+        "@hapi/boom": "^9.1.3",
+        "async-mutex": "^0.5.0",
+        "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
+        "lru-cache": "^11.1.0",
+        "music-metadata": "^11.7.0",
+        "p-queue": "^9.0.0",
+        "pino": "^9.6",
+        "protobufjs": "^7.2.4",
+        "ws": "^8.13.0"
+      },
+      "engines": {
+        "node": ">=20.0.0"
+      },
+      "peerDependencies": {
+        "audio-decode": "^2.1.3",
+        "jimp": "^1.6.0",
+        "link-preview-js": "^3.0.0",
+        "sharp": "*"
+      },
+      "peerDependenciesMeta": {
+        "audio-decode": {
+          "optional": true
+        },
+        "jimp": {
+          "optional": true
+        },
+        "link-preview-js": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/async-mutex": {
+      "version": "0.5.0",
+      "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
+      "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/atomic-sleep": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
+      "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/cacheable": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
+      "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@cacheable/memory": "^2.0.8",
+        "@cacheable/utils": "^2.4.0",
+        "hookified": "^1.15.0",
+        "keyv": "^5.6.0",
+        "qified": "^0.6.0"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/curve25519-js": {
+      "version": "0.0.4",
+      "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
+      "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.1.3"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/eventemitter3": {
+      "version": "5.0.4",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+      "license": "MIT"
+    },
+    "node_modules/file-type": {
+      "version": "21.3.0",
+      "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
+      "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
+      "license": "MIT",
+      "dependencies": {
+        "@tokenizer/inflate": "^0.4.1",
+        "strtok3": "^10.3.4",
+        "token-types": "^6.1.1",
+        "uint8array-extras": "^1.4.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/file-type?sponsor=1"
+      }
+    },
+    "node_modules/hashery": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
+      "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
+      "license": "MIT",
+      "dependencies": {
+        "hookified": "^1.14.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/hookified": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+      "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+      "license": "MIT"
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/keyv": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+      "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
+      "license": "MIT",
+      "dependencies": {
+        "@keyv/serialize": "^1.1.1"
+      }
+    },
+    "node_modules/libsignal": {
+      "name": "@whiskeysockets/libsignal-node",
+      "version": "2.0.1",
+      "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
+      "license": "GPL-3.0",
+      "dependencies": {
+        "curve25519-js": "^0.0.4",
+        "protobufjs": "6.8.8"
+      }
+    },
+    "node_modules/libsignal/node_modules/@types/node": {
+      "version": "10.17.60",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
+      "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
+      "license": "MIT"
+    },
+    "node_modules/libsignal/node_modules/long": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
+      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/libsignal/node_modules/protobufjs": {
+      "version": "6.8.8",
+      "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
+      "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
+      "hasInstallScript": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@protobufjs/aspromise": "^1.1.2",
+        "@protobufjs/base64": "^1.1.2",
+        "@protobufjs/codegen": "^2.0.4",
+        "@protobufjs/eventemitter": "^1.1.0",
+        "@protobufjs/fetch": "^1.1.0",
+        "@protobufjs/float": "^1.0.2",
+        "@protobufjs/inquire": "^1.1.0",
+        "@protobufjs/path": "^1.1.2",
+        "@protobufjs/pool": "^1.1.0",
+        "@protobufjs/utf8": "^1.1.0",
+        "@types/long": "^4.0.0",
+        "@types/node": "^10.1.0",
+        "long": "^4.0.0"
+      },
+      "bin": {
+        "pbjs": "bin/pbjs",
+        "pbts": "bin/pbts"
+      }
+    },
+    "node_modules/long": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
+      "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/lru-cache": {
+      "version": "11.2.6",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
+      "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
+      "license": "BlueOak-1.0.0",
+      "engines": {
+        "node": "20 || >=22"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/music-metadata": {
+      "version": "11.12.1",
+      "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
+      "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/Borewit"
+        },
+        {
+          "type": "buymeacoffee",
+          "url": "https://buymeacoffee.com/borewit"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "@borewit/text-codec": "^0.2.1",
+        "@tokenizer/token": "^0.3.0",
+        "content-type": "^1.0.5",
+        "debug": "^4.4.3",
+        "file-type": "^21.3.0",
+        "media-typer": "^1.1.0",
+        "strtok3": "^10.3.4",
+        "token-types": "^6.1.2",
+        "uint8array-extras": "^1.5.0",
+        "win-guid": "^0.2.1"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/on-exit-leak-free": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
+      "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/p-queue": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
+      "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
+      "license": "MIT",
+      "dependencies": {
+        "eventemitter3": "^5.0.1",
+        "p-timeout": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-timeout": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
+      "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/pino": {
+      "version": "9.14.0",
+      "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
+      "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
+      "license": "MIT",
+      "dependencies": {
+        "@pinojs/redact": "^0.4.0",
+        "atomic-sleep": "^1.0.0",
+        "on-exit-leak-free": "^2.1.0",
+        "pino-abstract-transport": "^2.0.0",
+        "pino-std-serializers": "^7.0.0",
+        "process-warning": "^5.0.0",
+        "quick-format-unescaped": "^4.0.3",
+        "real-require": "^0.2.0",
+        "safe-stable-stringify": "^2.3.1",
+        "sonic-boom": "^4.0.1",
+        "thread-stream": "^3.0.0"
+      },
+      "bin": {
+        "pino": "bin.js"
+      }
+    },
+    "node_modules/pino-abstract-transport": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
+      "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.0.0"
+      }
+    },
+    "node_modules/pino-std-serializers": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
+      "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
+      "license": "MIT"
+    },
+    "node_modules/process-warning": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
+      "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fastify"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/fastify"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/protobufjs": {
+      "version": "7.5.4",
+      "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
+      "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
+      "hasInstallScript": true,
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "@protobufjs/aspromise": "^1.1.2",
+        "@protobufjs/base64": "^1.1.2",
+        "@protobufjs/codegen": "^2.0.4",
+        "@protobufjs/eventemitter": "^1.1.0",
+        "@protobufjs/fetch": "^1.1.0",
+        "@protobufjs/float": "^1.0.2",
+        "@protobufjs/inquire": "^1.1.0",
+        "@protobufjs/path": "^1.1.2",
+        "@protobufjs/pool": "^1.1.0",
+        "@protobufjs/utf8": "^1.1.0",
+        "@types/node": ">=13.7.0",
+        "long": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/qified": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
+      "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
+      "license": "MIT",
+      "dependencies": {
+        "hookified": "^1.14.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/qrcode-terminal": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
+      "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
+      "bin": {
+        "qrcode-terminal": "bin/qrcode-terminal.js"
+      }
+    },
+    "node_modules/quick-format-unescaped": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
+      "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
+      "license": "MIT"
+    },
+    "node_modules/real-require": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
+      "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 12.13.0"
+      }
+    },
+    "node_modules/safe-stable-stringify": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
+      "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "peer": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "peer": true,
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/sonic-boom": {
+      "version": "4.2.1",
+      "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
+      "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
+      "license": "MIT",
+      "dependencies": {
+        "atomic-sleep": "^1.0.0"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/strtok3": {
+      "version": "10.3.4",
+      "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
+      "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
+      "license": "MIT",
+      "dependencies": {
+        "@tokenizer/token": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
+    "node_modules/thread-stream": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
+      "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
+      "license": "MIT",
+      "dependencies": {
+        "real-require": "^0.2.0"
+      }
+    },
+    "node_modules/token-types": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
+      "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
+      "license": "MIT",
+      "dependencies": {
+        "@borewit/text-codec": "^0.2.1",
+        "@tokenizer/token": "^0.3.0",
+        "ieee754": "^1.2.1"
+      },
+      "engines": {
+        "node": ">=14.16"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/Borewit"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/uint8array-extras": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
+      "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "license": "MIT"
+    },
+    "node_modules/win-guid": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
+      "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
+      "license": "MIT"
+    },
+    "node_modules/ws": {
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    }
+  }
+}
diff --git a/bridge/src/server.ts b/bridge/src/server.ts
index ec5573a..7d48f5e 100644
--- a/bridge/src/server.ts
+++ b/bridge/src/server.ts
@@ -12,13 +12,6 @@ interface SendCommand {
   text: string;
 }
 
-interface SendImageCommand {
-  type: 'send_image';
-  to: string;
-  imagePath: string;
-  caption?: string;
-}
-
 interface BridgeMessage {
   type: 'message' | 'status' | 'qr' | 'error';
   [key: string]: unknown;
@@ -79,7 +72,7 @@ export class BridgeServer {
 
     ws.on('message', async (data) => {
       try {
-        const cmd = JSON.parse(data.toString()) as SendCommand | SendImageCommand;
+        const cmd = JSON.parse(data.toString()) as SendCommand;
         await this.handleCommand(cmd);
         ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
       } catch (error) {
@@ -99,11 +92,9 @@ export class BridgeServer {
     });
   }
 
-  private async handleCommand(cmd: SendCommand | SendImageCommand): Promise {
+  private async handleCommand(cmd: SendCommand): Promise {
     if (cmd.type === 'send' && this.wa) {
       await this.wa.sendMessage(cmd.to, cmd.text);
-    } else if (cmd.type === 'send_image' && this.wa) {
-      await this.wa.sendImage(cmd.to, cmd.imagePath, cmd.caption);
     }
   }
 
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index d34100f..793e518 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -16,7 +16,7 @@ import makeWASocket, {
 import { Boom } from '@hapi/boom';
 import qrcode from 'qrcode-terminal';
 import pino from 'pino';
-import { writeFile, mkdir, readFile } from 'fs/promises';
+import { writeFile, mkdir } from 'fs/promises';
 import { join } from 'path';
 import { homedir } from 'os';
 import { randomBytes } from 'crypto';
@@ -217,18 +217,6 @@ export class WhatsAppClient {
     await this.sock.sendMessage(to, { text });
   }
 
-  async sendImage(to: string, imagePath: string, caption?: string): Promise {
-    if (!this.sock) {
-      throw new Error('Not connected');
-    }
-
-    const buffer = await readFile(imagePath);
-    await this.sock.sendMessage(to, {
-      image: buffer,
-      caption: caption || undefined,
-    });
-  }
-
   async disconnect(): Promise {
     if (this.sock) {
       this.sock.end(undefined);
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 1a96753..21793b7 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -83,26 +83,12 @@ class WhatsAppChannel(BaseChannel):
             return
 
         try:
-            # Send media files first
-            for media_path in (msg.media or []):
-                try:
-                    payload = {
-                        "type": "send_image",
-                        "to": msg.chat_id,
-                        "imagePath": media_path,
-                    }
-                    await self._ws.send(json.dumps(payload, ensure_ascii=False))
-                except Exception as e:
-                    logger.error("Error sending WhatsApp media {}: {}", media_path, e)
-
-            # Send text message if there's content
-            if msg.content:
-                payload = {
-                    "type": "send",
-                    "to": msg.chat_id,
-                    "text": msg.content
-                }
-                await self._ws.send(json.dumps(payload, ensure_ascii=False))
+            payload = {
+                "type": "send",
+                "to": msg.chat_id,
+                "text": msg.content
+            }
+            await self._ws.send(json.dumps(payload, ensure_ascii=False))
         except Exception as e:
             logger.error("Error sending WhatsApp message: {}", e)
 

From 067965da507853d29d9939095cd06d232871005f Mon Sep 17 00:00:00 2001
From: fat-operator 
Date: Sat, 7 Mar 2026 00:13:38 +0000
Subject: [PATCH 076/113] Refactored from image support to generic media

---
 bridge/package-lock.json     | 1362 ----------------------------------
 bridge/src/whatsapp.ts       |   47 +-
 nanobot/channels/whatsapp.py |   13 +-
 3 files changed, 37 insertions(+), 1385 deletions(-)
 delete mode 100644 bridge/package-lock.json

diff --git a/bridge/package-lock.json b/bridge/package-lock.json
deleted file mode 100644
index 7847d20..0000000
--- a/bridge/package-lock.json
+++ /dev/null
@@ -1,1362 +0,0 @@
-{
-  "name": "nanobot-whatsapp-bridge",
-  "version": "0.1.0",
-  "lockfileVersion": 3,
-  "requires": true,
-  "packages": {
-    "": {
-      "name": "nanobot-whatsapp-bridge",
-      "version": "0.1.0",
-      "dependencies": {
-        "@whiskeysockets/baileys": "7.0.0-rc.9",
-        "pino": "^9.0.0",
-        "qrcode-terminal": "^0.12.0",
-        "ws": "^8.17.1"
-      },
-      "devDependencies": {
-        "@types/node": "^20.14.0",
-        "@types/ws": "^8.5.10",
-        "typescript": "^5.4.0"
-      },
-      "engines": {
-        "node": ">=20.0.0"
-      }
-    },
-    "node_modules/@borewit/text-codec": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.1.tgz",
-      "integrity": "sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==",
-      "license": "MIT",
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
-    "node_modules/@cacheable/memory": {
-      "version": "2.0.8",
-      "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz",
-      "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==",
-      "license": "MIT",
-      "dependencies": {
-        "@cacheable/utils": "^2.4.0",
-        "@keyv/bigmap": "^1.3.1",
-        "hookified": "^1.15.1",
-        "keyv": "^5.6.0"
-      }
-    },
-    "node_modules/@cacheable/node-cache": {
-      "version": "1.7.6",
-      "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz",
-      "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==",
-      "license": "MIT",
-      "dependencies": {
-        "cacheable": "^2.3.1",
-        "hookified": "^1.14.0",
-        "keyv": "^5.5.5"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@cacheable/utils": {
-      "version": "2.4.0",
-      "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.0.tgz",
-      "integrity": "sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==",
-      "license": "MIT",
-      "dependencies": {
-        "hashery": "^1.5.0",
-        "keyv": "^5.6.0"
-      }
-    },
-    "node_modules/@emnapi/runtime": {
-      "version": "1.8.1",
-      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
-      "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==",
-      "license": "MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/@hapi/boom": {
-      "version": "9.1.4",
-      "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz",
-      "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@hapi/hoek": "9.x.x"
-      }
-    },
-    "node_modules/@hapi/hoek": {
-      "version": "9.3.0",
-      "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
-      "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@img/colour": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
-      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
-      "license": "MIT",
-      "peer": true,
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/@img/sharp-darwin-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
-      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-darwin-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-darwin-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
-      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-darwin-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-libvips-darwin-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
-      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-darwin-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
-      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "darwin"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-arm": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
-      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
-      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-ppc64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
-      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-riscv64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
-      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
-      "cpu": [
-        "riscv64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-s390x": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
-      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linux-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
-      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
-      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
-      "version": "1.2.4",
-      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
-      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-linux-arm": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
-      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
-      "cpu": [
-        "arm"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-arm": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
-      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-ppc64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
-      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
-      "cpu": [
-        "ppc64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-ppc64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-riscv64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
-      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
-      "cpu": [
-        "riscv64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-riscv64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-s390x": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
-      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
-      "cpu": [
-        "s390x"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-s390x": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linux-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
-      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linux-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linuxmusl-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
-      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-linuxmusl-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
-      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0",
-      "optional": true,
-      "os": [
-        "linux"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
-      }
-    },
-    "node_modules/@img/sharp-wasm32": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
-      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
-      "cpu": [
-        "wasm32"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
-      "optional": true,
-      "peer": true,
-      "dependencies": {
-        "@emnapi/runtime": "^1.7.0"
-      },
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-arm64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
-      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
-      "cpu": [
-        "arm64"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-ia32": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
-      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
-      "cpu": [
-        "ia32"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@img/sharp-win32-x64": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
-      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
-      "cpu": [
-        "x64"
-      ],
-      "license": "Apache-2.0 AND LGPL-3.0-or-later",
-      "optional": true,
-      "os": [
-        "win32"
-      ],
-      "peer": true,
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      }
-    },
-    "node_modules/@keyv/bigmap": {
-      "version": "1.3.1",
-      "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
-      "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
-      "license": "MIT",
-      "dependencies": {
-        "hashery": "^1.4.0",
-        "hookified": "^1.15.0"
-      },
-      "engines": {
-        "node": ">= 18"
-      },
-      "peerDependencies": {
-        "keyv": "^5.6.0"
-      }
-    },
-    "node_modules/@keyv/serialize": {
-      "version": "1.1.1",
-      "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
-      "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
-      "license": "MIT"
-    },
-    "node_modules/@pinojs/redact": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
-      "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
-      "license": "MIT"
-    },
-    "node_modules/@protobufjs/aspromise": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
-      "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/base64": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
-      "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/codegen": {
-      "version": "2.0.4",
-      "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
-      "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/eventemitter": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
-      "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/fetch": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
-      "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@protobufjs/aspromise": "^1.1.1",
-        "@protobufjs/inquire": "^1.1.0"
-      }
-    },
-    "node_modules/@protobufjs/float": {
-      "version": "1.0.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
-      "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/inquire": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
-      "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/path": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
-      "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/pool": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
-      "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@protobufjs/utf8": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
-      "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/@tokenizer/inflate": {
-      "version": "0.4.1",
-      "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz",
-      "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==",
-      "license": "MIT",
-      "dependencies": {
-        "debug": "^4.4.3",
-        "token-types": "^6.1.1"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
-    "node_modules/@tokenizer/token": {
-      "version": "0.3.0",
-      "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
-      "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
-      "license": "MIT"
-    },
-    "node_modules/@types/long": {
-      "version": "4.0.2",
-      "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz",
-      "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
-      "license": "MIT"
-    },
-    "node_modules/@types/node": {
-      "version": "20.19.37",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
-      "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
-      "license": "MIT",
-      "dependencies": {
-        "undici-types": "~6.21.0"
-      }
-    },
-    "node_modules/@types/ws": {
-      "version": "8.18.1",
-      "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
-      "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@types/node": "*"
-      }
-    },
-    "node_modules/@whiskeysockets/baileys": {
-      "version": "7.0.0-rc.9",
-      "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-7.0.0-rc.9.tgz",
-      "integrity": "sha512-YFm5gKXfDP9byCXCW3OPHKXLzrAKzolzgVUlRosHHgwbnf2YOO3XknkMm6J7+F0ns8OA0uuSBhgkRHTDtqkacw==",
-      "hasInstallScript": true,
-      "license": "MIT",
-      "dependencies": {
-        "@cacheable/node-cache": "^1.4.0",
-        "@hapi/boom": "^9.1.3",
-        "async-mutex": "^0.5.0",
-        "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git",
-        "lru-cache": "^11.1.0",
-        "music-metadata": "^11.7.0",
-        "p-queue": "^9.0.0",
-        "pino": "^9.6",
-        "protobufjs": "^7.2.4",
-        "ws": "^8.13.0"
-      },
-      "engines": {
-        "node": ">=20.0.0"
-      },
-      "peerDependencies": {
-        "audio-decode": "^2.1.3",
-        "jimp": "^1.6.0",
-        "link-preview-js": "^3.0.0",
-        "sharp": "*"
-      },
-      "peerDependenciesMeta": {
-        "audio-decode": {
-          "optional": true
-        },
-        "jimp": {
-          "optional": true
-        },
-        "link-preview-js": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/async-mutex": {
-      "version": "0.5.0",
-      "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
-      "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
-      "license": "MIT",
-      "dependencies": {
-        "tslib": "^2.4.0"
-      }
-    },
-    "node_modules/atomic-sleep": {
-      "version": "1.0.0",
-      "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
-      "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=8.0.0"
-      }
-    },
-    "node_modules/cacheable": {
-      "version": "2.3.3",
-      "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.3.tgz",
-      "integrity": "sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==",
-      "license": "MIT",
-      "dependencies": {
-        "@cacheable/memory": "^2.0.8",
-        "@cacheable/utils": "^2.4.0",
-        "hookified": "^1.15.0",
-        "keyv": "^5.6.0",
-        "qified": "^0.6.0"
-      }
-    },
-    "node_modules/content-type": {
-      "version": "1.0.5",
-      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
-      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.6"
-      }
-    },
-    "node_modules/curve25519-js": {
-      "version": "0.0.4",
-      "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz",
-      "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==",
-      "license": "MIT"
-    },
-    "node_modules/debug": {
-      "version": "4.4.3",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
-      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
-      "license": "MIT",
-      "dependencies": {
-        "ms": "^2.1.3"
-      },
-      "engines": {
-        "node": ">=6.0"
-      },
-      "peerDependenciesMeta": {
-        "supports-color": {
-          "optional": true
-        }
-      }
-    },
-    "node_modules/detect-libc": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
-      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
-      "license": "Apache-2.0",
-      "peer": true,
-      "engines": {
-        "node": ">=8"
-      }
-    },
-    "node_modules/eventemitter3": {
-      "version": "5.0.4",
-      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
-      "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
-      "license": "MIT"
-    },
-    "node_modules/file-type": {
-      "version": "21.3.0",
-      "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.0.tgz",
-      "integrity": "sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==",
-      "license": "MIT",
-      "dependencies": {
-        "@tokenizer/inflate": "^0.4.1",
-        "strtok3": "^10.3.4",
-        "token-types": "^6.1.1",
-        "uint8array-extras": "^1.4.0"
-      },
-      "engines": {
-        "node": ">=20"
-      },
-      "funding": {
-        "url": "https://github.com/sindresorhus/file-type?sponsor=1"
-      }
-    },
-    "node_modules/hashery": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.0.tgz",
-      "integrity": "sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==",
-      "license": "MIT",
-      "dependencies": {
-        "hookified": "^1.14.0"
-      },
-      "engines": {
-        "node": ">=20"
-      }
-    },
-    "node_modules/hookified": {
-      "version": "1.15.1",
-      "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
-      "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
-      "license": "MIT"
-    },
-    "node_modules/ieee754": {
-      "version": "1.2.1",
-      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
-      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/feross"
-        },
-        {
-          "type": "patreon",
-          "url": "https://www.patreon.com/feross"
-        },
-        {
-          "type": "consulting",
-          "url": "https://feross.org/support"
-        }
-      ],
-      "license": "BSD-3-Clause"
-    },
-    "node_modules/keyv": {
-      "version": "5.6.0",
-      "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
-      "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
-      "license": "MIT",
-      "dependencies": {
-        "@keyv/serialize": "^1.1.1"
-      }
-    },
-    "node_modules/libsignal": {
-      "name": "@whiskeysockets/libsignal-node",
-      "version": "2.0.1",
-      "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67",
-      "license": "GPL-3.0",
-      "dependencies": {
-        "curve25519-js": "^0.0.4",
-        "protobufjs": "6.8.8"
-      }
-    },
-    "node_modules/libsignal/node_modules/@types/node": {
-      "version": "10.17.60",
-      "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz",
-      "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
-      "license": "MIT"
-    },
-    "node_modules/libsignal/node_modules/long": {
-      "version": "4.0.0",
-      "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz",
-      "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==",
-      "license": "Apache-2.0"
-    },
-    "node_modules/libsignal/node_modules/protobufjs": {
-      "version": "6.8.8",
-      "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz",
-      "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==",
-      "hasInstallScript": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@protobufjs/aspromise": "^1.1.2",
-        "@protobufjs/base64": "^1.1.2",
-        "@protobufjs/codegen": "^2.0.4",
-        "@protobufjs/eventemitter": "^1.1.0",
-        "@protobufjs/fetch": "^1.1.0",
-        "@protobufjs/float": "^1.0.2",
-        "@protobufjs/inquire": "^1.1.0",
-        "@protobufjs/path": "^1.1.2",
-        "@protobufjs/pool": "^1.1.0",
-        "@protobufjs/utf8": "^1.1.0",
-        "@types/long": "^4.0.0",
-        "@types/node": "^10.1.0",
-        "long": "^4.0.0"
-      },
-      "bin": {
-        "pbjs": "bin/pbjs",
-        "pbts": "bin/pbts"
-      }
-    },
-    "node_modules/long": {
-      "version": "5.3.2",
-      "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
-      "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
-      "license": "Apache-2.0"
-    },
-    "node_modules/lru-cache": {
-      "version": "11.2.6",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz",
-      "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==",
-      "license": "BlueOak-1.0.0",
-      "engines": {
-        "node": "20 || >=22"
-      }
-    },
-    "node_modules/media-typer": {
-      "version": "1.1.0",
-      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
-      "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 0.8"
-      }
-    },
-    "node_modules/ms": {
-      "version": "2.1.3",
-      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
-      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
-      "license": "MIT"
-    },
-    "node_modules/music-metadata": {
-      "version": "11.12.1",
-      "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.1.tgz",
-      "integrity": "sha512-j++ltLxHDb5VCXET9FzQ8bnueiLHwQKgCO7vcbkRH/3F7fRjPkv6qncGEJ47yFhmemcYtgvsOAlcQ1dRBTkDjg==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/Borewit"
-        },
-        {
-          "type": "buymeacoffee",
-          "url": "https://buymeacoffee.com/borewit"
-        }
-      ],
-      "license": "MIT",
-      "dependencies": {
-        "@borewit/text-codec": "^0.2.1",
-        "@tokenizer/token": "^0.3.0",
-        "content-type": "^1.0.5",
-        "debug": "^4.4.3",
-        "file-type": "^21.3.0",
-        "media-typer": "^1.1.0",
-        "strtok3": "^10.3.4",
-        "token-types": "^6.1.2",
-        "uint8array-extras": "^1.5.0",
-        "win-guid": "^0.2.1"
-      },
-      "engines": {
-        "node": ">=18"
-      }
-    },
-    "node_modules/on-exit-leak-free": {
-      "version": "2.1.2",
-      "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
-      "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=14.0.0"
-      }
-    },
-    "node_modules/p-queue": {
-      "version": "9.1.0",
-      "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.1.0.tgz",
-      "integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
-      "license": "MIT",
-      "dependencies": {
-        "eventemitter3": "^5.0.1",
-        "p-timeout": "^7.0.0"
-      },
-      "engines": {
-        "node": ">=20"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/p-timeout": {
-      "version": "7.0.1",
-      "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz",
-      "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=20"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/pino": {
-      "version": "9.14.0",
-      "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz",
-      "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==",
-      "license": "MIT",
-      "dependencies": {
-        "@pinojs/redact": "^0.4.0",
-        "atomic-sleep": "^1.0.0",
-        "on-exit-leak-free": "^2.1.0",
-        "pino-abstract-transport": "^2.0.0",
-        "pino-std-serializers": "^7.0.0",
-        "process-warning": "^5.0.0",
-        "quick-format-unescaped": "^4.0.3",
-        "real-require": "^0.2.0",
-        "safe-stable-stringify": "^2.3.1",
-        "sonic-boom": "^4.0.1",
-        "thread-stream": "^3.0.0"
-      },
-      "bin": {
-        "pino": "bin.js"
-      }
-    },
-    "node_modules/pino-abstract-transport": {
-      "version": "2.0.0",
-      "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz",
-      "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==",
-      "license": "MIT",
-      "dependencies": {
-        "split2": "^4.0.0"
-      }
-    },
-    "node_modules/pino-std-serializers": {
-      "version": "7.1.0",
-      "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
-      "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
-      "license": "MIT"
-    },
-    "node_modules/process-warning": {
-      "version": "5.0.0",
-      "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
-      "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
-      "funding": [
-        {
-          "type": "github",
-          "url": "https://github.com/sponsors/fastify"
-        },
-        {
-          "type": "opencollective",
-          "url": "https://opencollective.com/fastify"
-        }
-      ],
-      "license": "MIT"
-    },
-    "node_modules/protobufjs": {
-      "version": "7.5.4",
-      "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
-      "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
-      "hasInstallScript": true,
-      "license": "BSD-3-Clause",
-      "dependencies": {
-        "@protobufjs/aspromise": "^1.1.2",
-        "@protobufjs/base64": "^1.1.2",
-        "@protobufjs/codegen": "^2.0.4",
-        "@protobufjs/eventemitter": "^1.1.0",
-        "@protobufjs/fetch": "^1.1.0",
-        "@protobufjs/float": "^1.0.2",
-        "@protobufjs/inquire": "^1.1.0",
-        "@protobufjs/path": "^1.1.2",
-        "@protobufjs/pool": "^1.1.0",
-        "@protobufjs/utf8": "^1.1.0",
-        "@types/node": ">=13.7.0",
-        "long": "^5.0.0"
-      },
-      "engines": {
-        "node": ">=12.0.0"
-      }
-    },
-    "node_modules/qified": {
-      "version": "0.6.0",
-      "resolved": "https://registry.npmjs.org/qified/-/qified-0.6.0.tgz",
-      "integrity": "sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==",
-      "license": "MIT",
-      "dependencies": {
-        "hookified": "^1.14.0"
-      },
-      "engines": {
-        "node": ">=20"
-      }
-    },
-    "node_modules/qrcode-terminal": {
-      "version": "0.12.0",
-      "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz",
-      "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==",
-      "bin": {
-        "qrcode-terminal": "bin/qrcode-terminal.js"
-      }
-    },
-    "node_modules/quick-format-unescaped": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
-      "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
-      "license": "MIT"
-    },
-    "node_modules/real-require": {
-      "version": "0.2.0",
-      "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
-      "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">= 12.13.0"
-      }
-    },
-    "node_modules/safe-stable-stringify": {
-      "version": "2.5.0",
-      "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
-      "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/semver": {
-      "version": "7.7.4",
-      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
-      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
-      "license": "ISC",
-      "peer": true,
-      "bin": {
-        "semver": "bin/semver.js"
-      },
-      "engines": {
-        "node": ">=10"
-      }
-    },
-    "node_modules/sharp": {
-      "version": "0.34.5",
-      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
-      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
-      "hasInstallScript": true,
-      "license": "Apache-2.0",
-      "peer": true,
-      "dependencies": {
-        "@img/colour": "^1.0.0",
-        "detect-libc": "^2.1.2",
-        "semver": "^7.7.3"
-      },
-      "engines": {
-        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
-      },
-      "funding": {
-        "url": "https://opencollective.com/libvips"
-      },
-      "optionalDependencies": {
-        "@img/sharp-darwin-arm64": "0.34.5",
-        "@img/sharp-darwin-x64": "0.34.5",
-        "@img/sharp-libvips-darwin-arm64": "1.2.4",
-        "@img/sharp-libvips-darwin-x64": "1.2.4",
-        "@img/sharp-libvips-linux-arm": "1.2.4",
-        "@img/sharp-libvips-linux-arm64": "1.2.4",
-        "@img/sharp-libvips-linux-ppc64": "1.2.4",
-        "@img/sharp-libvips-linux-riscv64": "1.2.4",
-        "@img/sharp-libvips-linux-s390x": "1.2.4",
-        "@img/sharp-libvips-linux-x64": "1.2.4",
-        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
-        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
-        "@img/sharp-linux-arm": "0.34.5",
-        "@img/sharp-linux-arm64": "0.34.5",
-        "@img/sharp-linux-ppc64": "0.34.5",
-        "@img/sharp-linux-riscv64": "0.34.5",
-        "@img/sharp-linux-s390x": "0.34.5",
-        "@img/sharp-linux-x64": "0.34.5",
-        "@img/sharp-linuxmusl-arm64": "0.34.5",
-        "@img/sharp-linuxmusl-x64": "0.34.5",
-        "@img/sharp-wasm32": "0.34.5",
-        "@img/sharp-win32-arm64": "0.34.5",
-        "@img/sharp-win32-ia32": "0.34.5",
-        "@img/sharp-win32-x64": "0.34.5"
-      }
-    },
-    "node_modules/sonic-boom": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
-      "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
-      "license": "MIT",
-      "dependencies": {
-        "atomic-sleep": "^1.0.0"
-      }
-    },
-    "node_modules/split2": {
-      "version": "4.2.0",
-      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
-      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
-      "license": "ISC",
-      "engines": {
-        "node": ">= 10.x"
-      }
-    },
-    "node_modules/strtok3": {
-      "version": "10.3.4",
-      "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
-      "integrity": "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==",
-      "license": "MIT",
-      "dependencies": {
-        "@tokenizer/token": "^0.3.0"
-      },
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
-    "node_modules/thread-stream": {
-      "version": "3.1.0",
-      "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz",
-      "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==",
-      "license": "MIT",
-      "dependencies": {
-        "real-require": "^0.2.0"
-      }
-    },
-    "node_modules/token-types": {
-      "version": "6.1.2",
-      "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz",
-      "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==",
-      "license": "MIT",
-      "dependencies": {
-        "@borewit/text-codec": "^0.2.1",
-        "@tokenizer/token": "^0.3.0",
-        "ieee754": "^1.2.1"
-      },
-      "engines": {
-        "node": ">=14.16"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/Borewit"
-      }
-    },
-    "node_modules/tslib": {
-      "version": "2.8.1",
-      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
-      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "license": "0BSD"
-    },
-    "node_modules/typescript": {
-      "version": "5.9.3",
-      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
-      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
-      "dev": true,
-      "license": "Apache-2.0",
-      "bin": {
-        "tsc": "bin/tsc",
-        "tsserver": "bin/tsserver"
-      },
-      "engines": {
-        "node": ">=14.17"
-      }
-    },
-    "node_modules/uint8array-extras": {
-      "version": "1.5.0",
-      "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz",
-      "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=18"
-      },
-      "funding": {
-        "url": "https://github.com/sponsors/sindresorhus"
-      }
-    },
-    "node_modules/undici-types": {
-      "version": "6.21.0",
-      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
-      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
-      "license": "MIT"
-    },
-    "node_modules/win-guid": {
-      "version": "0.2.1",
-      "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz",
-      "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==",
-      "license": "MIT"
-    },
-    "node_modules/ws": {
-      "version": "8.19.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
-      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
-      "license": "MIT",
-      "engines": {
-        "node": ">=10.0.0"
-      },
-      "peerDependencies": {
-        "bufferutil": "^4.0.1",
-        "utf-8-validate": ">=5.0.2"
-      },
-      "peerDependenciesMeta": {
-        "bufferutil": {
-          "optional": true
-        },
-        "utf-8-validate": {
-          "optional": true
-        }
-      }
-    }
-  }
-}
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts
index 793e518..279fe5a 100644
--- a/bridge/src/whatsapp.ts
+++ b/bridge/src/whatsapp.ts
@@ -127,7 +127,14 @@ export class WhatsAppClient {
         const mediaPaths: string[] = [];
 
         if (unwrapped.imageMessage) {
-          const path = await this.downloadImage(msg, unwrapped.imageMessage.mimetype ?? undefined);
+          const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
+          if (path) mediaPaths.push(path);
+        } else if (unwrapped.documentMessage) {
+          const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
+            unwrapped.documentMessage.fileName ?? undefined);
+          if (path) mediaPaths.push(path);
+        } else if (unwrapped.videoMessage) {
+          const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
           if (path) mediaPaths.push(path);
         }
 
@@ -148,29 +155,31 @@ export class WhatsAppClient {
     });
   }
 
-  private async downloadImage(msg: any, mimetype?: string): Promise {
+  private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise {
     try {
       const mediaDir = join(homedir(), '.nanobot', 'media');
       await mkdir(mediaDir, { recursive: true });
 
       const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
 
-      const mime = mimetype || 'image/jpeg';
-      const extMap: Record = {
-        'image/jpeg': '.jpg',
-        'image/png': '.png',
-        'image/gif': '.gif',
-        'image/webp': '.webp',
-      };
-      const ext = extMap[mime] || '.jpg';
+      let outFilename: string;
+      if (fileName) {
+        // Documents have a filename โ€” use it with a unique prefix to avoid collisions
+        const prefix = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_`;
+        outFilename = prefix + fileName;
+      } else {
+        const mime = mimetype || 'application/octet-stream';
+        // Derive extension from mimetype subtype (e.g. "image/png" โ†’ ".png", "application/pdf" โ†’ ".pdf")
+        const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
+        outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
+      }
 
-      const filename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
-      const filepath = join(mediaDir, filename);
+      const filepath = join(mediaDir, outFilename);
       await writeFile(filepath, buffer);
 
       return filepath;
     } catch (err) {
-      console.error('Failed to download image:', err);
+      console.error('Failed to download media:', err);
       return null;
     }
   }
@@ -191,14 +200,14 @@ export class WhatsAppClient {
       return message.imageMessage.caption || '';
     }
 
-    // Video with caption
-    if (message.videoMessage?.caption) {
-      return `[Video] ${message.videoMessage.caption}`;
+    // Video with optional caption
+    if (message.videoMessage) {
+      return message.videoMessage.caption || '';
     }
 
-    // Document with caption
-    if (message.documentMessage?.caption) {
-      return `[Document] ${message.documentMessage.caption}`;
+    // Document with optional caption
+    if (message.documentMessage) {
+      return message.documentMessage.caption || '';
     }
 
     // Voice/Audio message
diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py
index 21793b7..1307716 100644
--- a/nanobot/channels/whatsapp.py
+++ b/nanobot/channels/whatsapp.py
@@ -2,6 +2,7 @@
 
 import asyncio
 import json
+import mimetypes
 from collections import OrderedDict
 
 from loguru import logger
@@ -128,12 +129,16 @@ class WhatsAppChannel(BaseChannel):
                 logger.info("Voice message received from {}, but direct download from bridge is not yet supported.", sender_id)
                 content = "[Voice Message: Transcription not available for WhatsApp yet]"
 
-            # Extract media paths (images downloaded by the bridge)
+            # Extract media paths (images/documents/videos downloaded by the bridge)
             media_paths = data.get("media") or []
 
-            # For image messages without caption, provide descriptive content
-            if not content and media_paths:
-                content = "[image]"
+            # Build content tags matching Telegram's pattern: [image: /path] or [file: /path]
+            if media_paths:
+                for p in media_paths:
+                    mime, _ = mimetypes.guess_type(p)
+                    media_type = "image" if mime and mime.startswith("image/") else "file"
+                    media_tag = f"[{media_type}: {p}]"
+                    content = f"{content}\n{media_tag}" if content else media_tag
 
             await self._handle_message(
                 sender_id=sender_id,

From e3810573568d6ea269f5d9ebfaa39623ad2ea30c Mon Sep 17 00:00:00 2001
From: 04cb <0x04cb@gmail.com>
Date: Sat, 7 Mar 2026 08:31:15 +0800
Subject: [PATCH 077/113] Fix tool_call_id length error for GitHub Copilot
 provider

GitHub Copilot and some other providers have a 64-character limit on
tool_call_id. When switching from providers that generate longer IDs
(such as OpenAI Codex), this caused validation errors.

This fix truncates tool_call_id to 64 characters by preserving the first
32 and last 32 characters to maintain uniqueness while respecting the
provider's limit.

Fixes #1554
---
 nanobot/providers/litellm_provider.py | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 620424e..767c8da 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -169,6 +169,8 @@ class LiteLLMProvider(LLMProvider):
     @staticmethod
     def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
         """Strip non-standard keys and ensure assistant messages have a content key."""
+        # GitHub Copilot and some other providers have a 64-character limit on tool_call_id
+        MAX_TOOL_CALL_ID_LENGTH = 64
         allowed = _ALLOWED_MSG_KEYS | extra_keys
         sanitized = []
         for msg in messages:
@@ -176,6 +178,13 @@ class LiteLLMProvider(LLMProvider):
             # Strict providers require "content" even when assistant only has tool_calls
             if clean.get("role") == "assistant" and "content" not in clean:
                 clean["content"] = None
+            # Truncate tool_call_id if it exceeds the provider's limit
+            # This can happen when switching from providers that generate longer IDs
+            if "tool_call_id" in clean and clean["tool_call_id"]:
+                tool_call_id = clean["tool_call_id"]
+                if isinstance(tool_call_id, str) and len(tool_call_id) > MAX_TOOL_CALL_ID_LENGTH:
+                    # Preserve first 32 chars and last 32 chars to maintain uniqueness
+                    clean["tool_call_id"] = tool_call_id[:32] + tool_call_id[-32:]
             sanitized.append(clean)
         return sanitized
 

From 64112eb9ba985bae151b2c40a1760886823b5747 Mon Sep 17 00:00:00 2001
From: Re-bin 
Date: Sat, 7 Mar 2026 03:06:19 +0000
Subject: [PATCH 078/113] fix(whatsapp): avoid dropping media-only messages

---
 README.md              | 4 ++++
 bridge/src/whatsapp.ts | 9 +++++++--
 2 files changed, 11 insertions(+), 2 deletions(-)

diff --git a/README.md b/README.md
index 0c49608..d2a1c59 100644
--- a/README.md
+++ b/README.md
@@ -420,6 +420,10 @@ nanobot channels login
 nanobot gateway
 ```
 
+> WhatsApp bridge updates are not applied automatically for existing installations.
+> If you upgrade nanobot and need the latest WhatsApp bridge, run:
+> `rm -rf ~/.nanobot/bridge && nanobot channels login`
+
 
 
 
diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index 279fe5a..b91bacc 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -124,21 +124,26 @@ export class WhatsAppClient { if (!unwrapped) continue; const content = this.getTextContent(unwrapped); + let fallbackContent: string | null = null; const mediaPaths: string[] = []; if (unwrapped.imageMessage) { + fallbackContent = '[Image]'; const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined); if (path) mediaPaths.push(path); } else if (unwrapped.documentMessage) { + fallbackContent = '[Document]'; const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined, unwrapped.documentMessage.fileName ?? undefined); if (path) mediaPaths.push(path); } else if (unwrapped.videoMessage) { + fallbackContent = '[Video]'; const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined); if (path) mediaPaths.push(path); } - if (!content && mediaPaths.length === 0) continue; + const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || ''; + if (!finalContent && mediaPaths.length === 0) continue; const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false; @@ -146,7 +151,7 @@ export class WhatsAppClient { id: msg.key.id || '', sender: msg.key.remoteJid || '', pn: msg.key.remoteJidAlt || '', - content: content || '', + content: finalContent, timestamp: msg.messageTimestamp as number, isGroup, ...(mediaPaths.length > 0 ? { media: mediaPaths } : {}), From c94ac351f1a285e22fc0796a54a11d2821755ab6 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 03:30:36 +0000 Subject: [PATCH 079/113] fix(litellm): normalize tool call ids --- nanobot/providers/litellm_provider.py | 40 +++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 767c8da..2fd6c18 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -1,5 +1,6 @@ """LiteLLM provider implementation for multi-provider support.""" +import hashlib import os import secrets import string @@ -166,25 +167,48 @@ class LiteLLMProvider(LLMProvider): return _ANTHROPIC_EXTRA_KEYS return frozenset() + @staticmethod + def _normalize_tool_call_id(tool_call_id: Any) -> Any: + """Normalize tool_call_id to a provider-safe 9-char alphanumeric form.""" + if not isinstance(tool_call_id, str): + return tool_call_id + if len(tool_call_id) == 9 and tool_call_id.isalnum(): + return tool_call_id + return hashlib.sha1(tool_call_id.encode()).hexdigest()[:9] + @staticmethod def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" - # GitHub Copilot and some other providers have a 64-character limit on tool_call_id - MAX_TOOL_CALL_ID_LENGTH = 64 allowed = _ALLOWED_MSG_KEYS | extra_keys sanitized = [] + id_map: dict[str, str] = {} + + def map_id(value: Any) -> Any: + if not isinstance(value, str): + return value + return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value)) + for msg in messages: clean = {k: v for k, v in msg.items() if k in allowed} # Strict providers require "content" even when assistant only has tool_calls if clean.get("role") == "assistant" and "content" not in clean: clean["content"] = None - # Truncate tool_call_id if it exceeds the provider's limit - # This can happen when switching from providers that generate longer IDs + + # Keep assistant tool_calls[].id and tool tool_call_id in sync after + # shortening, otherwise strict providers reject the broken linkage. + if isinstance(clean.get("tool_calls"), list): + normalized_tool_calls = [] + for tc in clean["tool_calls"]: + if not isinstance(tc, dict): + normalized_tool_calls.append(tc) + continue + tc_clean = dict(tc) + tc_clean["id"] = map_id(tc_clean.get("id")) + normalized_tool_calls.append(tc_clean) + clean["tool_calls"] = normalized_tool_calls + if "tool_call_id" in clean and clean["tool_call_id"]: - tool_call_id = clean["tool_call_id"] - if isinstance(tool_call_id, str) and len(tool_call_id) > MAX_TOOL_CALL_ID_LENGTH: - # Preserve first 32 chars and last 32 chars to maintain uniqueness - clean["tool_call_id"] = tool_call_id[:32] + tool_call_id[-32:] + clean["tool_call_id"] = map_id(clean["tool_call_id"]) sanitized.append(clean) return sanitized From 576ad12ef16fbf7813fb88d46f43c48a23d98ed8 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 03:57:57 +0000 Subject: [PATCH 080/113] fix(azure): sanitize messages and handle temperature --- nanobot/providers/azure_openai_provider.py | 25 +++++++++- nanobot/providers/base.py | 14 ++++++ nanobot/providers/litellm_provider.py | 10 +--- tests/test_azure_openai_provider.py | 57 +++++++++++++++++++--- 4 files changed, 89 insertions(+), 17 deletions(-) diff --git a/nanobot/providers/azure_openai_provider.py b/nanobot/providers/azure_openai_provider.py index 3f325aa..bd79b00 100644 --- a/nanobot/providers/azure_openai_provider.py +++ b/nanobot/providers/azure_openai_provider.py @@ -11,6 +11,8 @@ import json_repair from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest +_AZURE_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name"}) + class AzureOpenAIProvider(LLMProvider): """ @@ -67,19 +69,38 @@ class AzureOpenAIProvider(LLMProvider): "x-session-affinity": uuid.uuid4().hex, # For cache locality } + @staticmethod + def _supports_temperature( + deployment_name: str, + reasoning_effort: str | None = None, + ) -> bool: + """Return True when temperature is likely supported for this deployment.""" + if reasoning_effort: + return False + name = deployment_name.lower() + return not any(token in name for token in ("gpt-5", "o1", "o3", "o4")) + def _prepare_request_payload( self, + deployment_name: str, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, max_tokens: int = 4096, + temperature: float = 0.7, reasoning_effort: str | None = None, ) -> dict[str, Any]: """Prepare the request payload with Azure OpenAI 2024-10-21 compliance.""" payload: dict[str, Any] = { - "messages": self._sanitize_empty_content(messages), + "messages": self._sanitize_request_messages( + self._sanitize_empty_content(messages), + _AZURE_MSG_KEYS, + ), "max_completion_tokens": max(1, max_tokens), # Azure API 2024-10-21 uses max_completion_tokens } + if self._supports_temperature(deployment_name, reasoning_effort): + payload["temperature"] = temperature + if reasoning_effort: payload["reasoning_effort"] = reasoning_effort @@ -116,7 +137,7 @@ class AzureOpenAIProvider(LLMProvider): url = self._build_chat_url(deployment_name) headers = self._build_headers() payload = self._prepare_request_payload( - messages, tools, max_tokens, reasoning_effort + deployment_name, messages, tools, max_tokens, temperature, reasoning_effort ) try: diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py index 55bd805..0f73544 100644 --- a/nanobot/providers/base.py +++ b/nanobot/providers/base.py @@ -87,6 +87,20 @@ class LLMProvider(ABC): result.append(msg) return result + @staticmethod + def _sanitize_request_messages( + messages: list[dict[str, Any]], + allowed_keys: frozenset[str], + ) -> list[dict[str, Any]]: + """Keep only provider-safe message keys and normalize assistant content.""" + sanitized = [] + for msg in messages: + clean = {k: v for k, v in msg.items() if k in allowed_keys} + if clean.get("role") == "assistant" and "content" not in clean: + clean["content"] = None + sanitized.append(clean) + return sanitized + @abstractmethod async def chat( self, diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 2fd6c18..cb67635 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -180,7 +180,7 @@ class LiteLLMProvider(LLMProvider): def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]: """Strip non-standard keys and ensure assistant messages have a content key.""" allowed = _ALLOWED_MSG_KEYS | extra_keys - sanitized = [] + sanitized = LLMProvider._sanitize_request_messages(messages, allowed) id_map: dict[str, str] = {} def map_id(value: Any) -> Any: @@ -188,12 +188,7 @@ class LiteLLMProvider(LLMProvider): return value return id_map.setdefault(value, LiteLLMProvider._normalize_tool_call_id(value)) - for msg in messages: - clean = {k: v for k, v in msg.items() if k in allowed} - # Strict providers require "content" even when assistant only has tool_calls - if clean.get("role") == "assistant" and "content" not in clean: - clean["content"] = None - + for clean in sanitized: # Keep assistant tool_calls[].id and tool tool_call_id in sync after # shortening, otherwise strict providers reject the broken linkage. if isinstance(clean.get("tool_calls"), list): @@ -209,7 +204,6 @@ class LiteLLMProvider(LLMProvider): if "tool_call_id" in clean and clean["tool_call_id"]: clean["tool_call_id"] = map_id(clean["tool_call_id"]) - sanitized.append(clean) return sanitized async def chat( diff --git a/tests/test_azure_openai_provider.py b/tests/test_azure_openai_provider.py index 680ddf4..77f36d4 100644 --- a/tests/test_azure_openai_provider.py +++ b/tests/test_azure_openai_provider.py @@ -1,9 +1,9 @@ """Test Azure OpenAI provider implementation (updated for model-based deployment names).""" -import asyncio -import pytest from unittest.mock import AsyncMock, Mock, patch +import pytest + from nanobot.providers.azure_openai_provider import AzureOpenAIProvider from nanobot.providers.base import LLMResponse @@ -89,22 +89,65 @@ def test_prepare_request_payload(): ) messages = [{"role": "user", "content": "Hello"}] - payload = provider._prepare_request_payload(messages, max_tokens=1500) + payload = provider._prepare_request_payload("gpt-4o", messages, max_tokens=1500, temperature=0.8) assert payload["messages"] == messages assert payload["max_completion_tokens"] == 1500 # Azure API 2024-10-21 uses max_completion_tokens - assert "temperature" not in payload # Temperature not included in payload + assert payload["temperature"] == 0.8 assert "tools" not in payload # Test with tools tools = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] - payload_with_tools = provider._prepare_request_payload(messages, tools=tools) + payload_with_tools = provider._prepare_request_payload("gpt-4o", messages, tools=tools) assert payload_with_tools["tools"] == tools assert payload_with_tools["tool_choice"] == "auto" # Test with reasoning_effort - payload_with_reasoning = provider._prepare_request_payload(messages, reasoning_effort="medium") + payload_with_reasoning = provider._prepare_request_payload( + "gpt-5-chat", messages, reasoning_effort="medium" + ) assert payload_with_reasoning["reasoning_effort"] == "medium" + assert "temperature" not in payload_with_reasoning + + +def test_prepare_request_payload_sanitizes_messages(): + """Test Azure payload strips non-standard message keys before sending.""" + provider = AzureOpenAIProvider( + api_key="test-key", + api_base="https://test-resource.openai.azure.com", + default_model="gpt-4o", + ) + + messages = [ + { + "role": "assistant", + "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}], + "reasoning_content": "hidden chain-of-thought", + }, + { + "role": "tool", + "tool_call_id": "call_123", + "name": "x", + "content": "ok", + "extra_field": "should be removed", + }, + ] + + payload = provider._prepare_request_payload("gpt-4o", messages) + + assert payload["messages"] == [ + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_123", "type": "function", "function": {"name": "x"}}], + }, + { + "role": "tool", + "tool_call_id": "call_123", + "name": "x", + "content": "ok", + }, + ] @pytest.mark.asyncio @@ -349,7 +392,7 @@ if __name__ == "__main__": # Test payload preparation messages = [{"role": "user", "content": "Test"}] - payload = provider._prepare_request_payload(messages, max_tokens=1000) + payload = provider._prepare_request_payload("gpt-4o-deployment", messages, max_tokens=1000) assert payload["max_completion_tokens"] == 1000 # Azure 2024-10-21 format print("โœ… Payload preparation works correctly") From c81d32c40f6c2baac34c73eec53c731fb00ae6d2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 04:07:25 +0000 Subject: [PATCH 081/113] fix(discord): handle attachment reply fallback --- nanobot/channels/discord.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 8672327..0187c62 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -84,20 +84,31 @@ class DiscordChannel(BaseChannel): headers = {"Authorization": f"Bot {self.config.token}"} try: + sent_media = False + failed_media: list[str] = [] + # Send file attachments first for media_path in msg.media or []: - await self._send_file(url, headers, media_path, reply_to=msg.reply_to) + if await self._send_file(url, headers, media_path, reply_to=msg.reply_to): + sent_media = True + else: + failed_media.append(Path(media_path).name) # Send text content chunks = split_message(msg.content or "", MAX_MESSAGE_LEN) + if not chunks and failed_media and not sent_media: + chunks = split_message( + "\n".join(f"[attachment: {name} - send failed]" for name in failed_media), + MAX_MESSAGE_LEN, + ) if not chunks: return for i, chunk in enumerate(chunks): payload: dict[str, Any] = {"content": chunk} - # Only set reply reference on the first chunk (if no media was sent) - if i == 0 and msg.reply_to and not msg.media: + # Let the first successful attachment carry the reply if present. + if i == 0 and msg.reply_to and not sent_media: payload["message_reference"] = {"message_id": msg.reply_to} payload["allowed_mentions"] = {"replied_user": False} From c3f2d1b01dbf02ec278c7714a85e7cc07f38280c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 05:28:12 +0000 Subject: [PATCH 082/113] fix(tools): narrow parameter auto-casting --- nanobot/agent/tools/base.py | 113 ++++++++++------------------------ tests/test_tool_validation.py | 54 +++++----------- 2 files changed, 48 insertions(+), 119 deletions(-) diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index fb34fe8..06f5bdd 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -3,8 +3,6 @@ from abc import ABC, abstractmethod from typing import Any -from loguru import logger - class Tool(ABC): """ @@ -55,11 +53,7 @@ class Tool(ABC): pass def cast_params(self, params: dict[str, Any]) -> dict[str, Any]: - """ - Attempt to cast parameters to match schema types. - Returns modified params dict. If casting fails, returns original value - and logs a debug message, allowing validation to catch the error. - """ + """Apply safe schema-driven casts before validation.""" schema = self.parameters or {} if schema.get("type", "object") != "object": return params @@ -86,91 +80,44 @@ class Tool(ABC): """Cast a single value according to schema.""" target_type = schema.get("type") - # Already correct type - # Note: check bool before int since bool is subclass of int if target_type == "boolean" and isinstance(val, bool): return val if target_type == "integer" and isinstance(val, int) and not isinstance(val, bool): return val - # For array/object, don't early-return - we need to recurse into contents - if target_type in self._TYPE_MAP and target_type not in ( - "boolean", - "integer", - "array", - "object", - ): + if target_type in self._TYPE_MAP and target_type not in ("boolean", "integer", "array", "object"): expected = self._TYPE_MAP[target_type] if isinstance(val, expected): return val - # Attempt casting - try: - if target_type == "integer": - if isinstance(val, bool): - # Don't silently convert bool to int - raise ValueError("Cannot cast bool to integer") - if isinstance(val, str): - return int(val) - if isinstance(val, (int, float)): - return int(val) + if target_type == "integer" and isinstance(val, str): + try: + return int(val) + except ValueError: + return val - elif target_type == "number": - if isinstance(val, bool): - # Don't silently convert bool to number - raise ValueError("Cannot cast bool to number") - if isinstance(val, str): - return float(val) - if isinstance(val, (int, float)): - return float(val) + if target_type == "number" and isinstance(val, str): + try: + return float(val) + except ValueError: + return val - elif target_type == "string": - # Preserve None vs empty string distinction - if val is None: - return val - return str(val) + if target_type == "string": + return val if val is None else str(val) - elif target_type == "boolean": - if isinstance(val, str): - val_lower = val.lower() - if val_lower in ("true", "1", "yes"): - return True - elif val_lower in ("false", "0", "no"): - return False - # For other strings, raise error to let validation handle it - raise ValueError(f"Cannot convert string '{val}' to boolean") - return bool(val) + if target_type == "boolean" and isinstance(val, str): + val_lower = val.lower() + if val_lower in ("true", "1", "yes"): + return True + if val_lower in ("false", "0", "no"): + return False + return val - elif target_type == "array": - if isinstance(val, list): - # Recursively cast array items if schema defines items - if "items" in schema: - return [self._cast_value(item, schema["items"]) for item in val] - return val - # Preserve None vs empty array distinction - if val is None: - return val - # Empty string โ†’ empty array - if val == "": - return [] - # Don't auto-wrap single values, let validation catch the error - raise ValueError(f"Cannot convert {type(val).__name__} to array") + if target_type == "array" and isinstance(val, list): + item_schema = schema.get("items") + return [self._cast_value(item, item_schema) for item in val] if item_schema else val - elif target_type == "object": - if isinstance(val, dict): - return self._cast_object(val, schema) - # Preserve None vs empty object distinction - if val is None: - return val - # Empty string โ†’ empty object - if val == "": - return {} - # Cannot cast to object - raise ValueError(f"Cannot cast {type(val).__name__} to object") - - except (ValueError, TypeError) as e: - # Log failed casts for debugging, return original value - # Let validation catch the error - logger.debug("Failed to cast value %r to %s: %s", val, target_type, e) + if target_type == "object" and isinstance(val, dict): + return self._cast_object(val, schema) return val @@ -185,7 +132,13 @@ class Tool(ABC): def _validate(self, val: Any, schema: dict[str, Any], path: str) -> list[str]: t, label = schema.get("type"), path or "parameter" - if t in self._TYPE_MAP and not isinstance(val, self._TYPE_MAP[t]): + if t == "integer" and (not isinstance(val, int) or isinstance(val, bool)): + return [f"{label} should be integer"] + if t == "number" and ( + not isinstance(val, self._TYPE_MAP[t]) or isinstance(val, bool) + ): + return [f"{label} should be number"] + if t in self._TYPE_MAP and t not in ("integer", "number") and not isinstance(val, self._TYPE_MAP[t]): return [f"{label} should be {t}"] errors = [] diff --git a/tests/test_tool_validation.py b/tests/test_tool_validation.py index 6fb87ea..c2b4b6a 100644 --- a/tests/test_tool_validation.py +++ b/tests/test_tool_validation.py @@ -210,9 +210,10 @@ def test_cast_params_bool_not_cast_to_int() -> None: "properties": {"count": {"type": "integer"}}, } ) - # Bool input should remain bool (validation will catch it) result = tool.cast_params({"count": True}) - assert result["count"] is True # Not cast to 1 + assert result["count"] is True + errors = tool.validate_params(result) + assert any("count should be integer" in e for e in errors) def test_cast_params_preserves_empty_string() -> None: @@ -283,6 +284,18 @@ def test_cast_params_invalid_string_to_number() -> None: assert result["rate"] == "not_a_number" +def test_validate_params_bool_not_accepted_as_number() -> None: + """Booleans should not pass number validation.""" + tool = CastTestTool( + { + "type": "object", + "properties": {"rate": {"type": "number"}}, + } + ) + errors = tool.validate_params({"rate": False}) + assert any("rate should be number" in e for e in errors) + + def test_cast_params_none_values() -> None: """Test None handling for different types.""" tool = CastTestTool( @@ -324,40 +337,3 @@ def test_cast_params_single_value_not_auto_wrapped_to_array() -> None: assert result["items"] == 5 # Not wrapped to [5] result = tool.cast_params({"items": "text"}) assert result["items"] == "text" # Not wrapped to ["text"] - - -def test_cast_params_empty_string_to_array() -> None: - """Empty string should convert to empty array.""" - tool = CastTestTool( - { - "type": "object", - "properties": {"items": {"type": "array"}}, - } - ) - result = tool.cast_params({"items": ""}) - assert result["items"] == [] - - -def test_cast_params_empty_string_to_object() -> None: - """Empty string should convert to empty object.""" - tool = CastTestTool( - { - "type": "object", - "properties": {"config": {"type": "object"}}, - } - ) - result = tool.cast_params({"config": ""}) - assert result["config"] == {} - - -def test_cast_params_float_to_int() -> None: - """Float values should be cast to integers.""" - tool = CastTestTool( - { - "type": "object", - "properties": {"count": {"type": "integer"}}, - } - ) - result = tool.cast_params({"count": 42.7}) - assert result["count"] == 42 - assert isinstance(result["count"], int) From 215360113fa967f197301352416d694697b049ba Mon Sep 17 00:00:00 2001 From: chengyongru Date: Sat, 7 Mar 2026 16:19:55 +0800 Subject: [PATCH 083/113] feat(feishu): add audio transcription support using Groq Whisper --- nanobot/channels/feishu.py | 15 ++++++++++++++- nanobot/channels/manager.py | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 8f69c09..611c95e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -244,9 +244,10 @@ class FeishuChannel(BaseChannel): name = "feishu" - def __init__(self, config: FeishuConfig, bus: MessageBus): + def __init__(self, config: FeishuConfig, bus: MessageBus, groq_api_key: str = ""): super().__init__(config, bus) self.config: FeishuConfig = config + self.groq_api_key = groq_api_key self._client: Any = None self._ws_client: Any = None self._ws_thread: threading.Thread | None = None @@ -909,6 +910,18 @@ class FeishuChannel(BaseChannel): file_path, content_text = await self._download_and_save_media(msg_type, content_json, message_id) if file_path: media_paths.append(file_path) + + # Transcribe audio using Groq Whisper + if msg_type == "audio" and file_path and self.groq_api_key: + try: + from nanobot.providers.transcription import GroqTranscriptionProvider + transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) + transcription = await transcriber.transcribe(file_path) + if transcription: + content_text = f"[transcription: {transcription}]" + except Exception as e: + logger.warning("Failed to transcribe audio: {}", e) + content_parts.append(content_text) elif msg_type in ("share_chat", "share_user", "interactive", "share_calendar_event", "system", "merge_forward"): diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 7d7d110..51539dd 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -74,7 +74,8 @@ class ChannelManager: try: from nanobot.channels.feishu import FeishuChannel self.channels["feishu"] = FeishuChannel( - self.config.channels.feishu, self.bus + self.config.channels.feishu, self.bus, + groq_api_key=self.config.providers.groq.api_key, ) logger.info("Feishu channel enabled") except ImportError as e: From cf76011c1aae1b397361f85751443b36b6418e79 Mon Sep 17 00:00:00 2001 From: VITOHJL Date: Sat, 7 Mar 2026 17:09:59 +0800 Subject: [PATCH 084/113] fix: hide reasoning_content from user progress updates --- nanobot/agent/loop.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 7f129a2..56a91c1 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -202,9 +202,10 @@ class AgentLoop: if response.has_tool_calls: if on_progress: + # Only show stripped content and thinking blocks in progress, not reasoning_content + # reasoning_content is internal thinking and should not be shown to users thoughts = [ self._strip_think(response.content), - response.reasoning_content, *( f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" for b in (response.thinking_blocks or []) From 44327d6457f87884954bde79c25415ba69134a41 Mon Sep 17 00:00:00 2001 From: Gleb Date: Sat, 7 Mar 2026 12:38:52 +0200 Subject: [PATCH 085/113] fix(telegram): added "stop" command handler, fixed stop command --- nanobot/channels/telegram.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index aaa24e7..c83edd3 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -197,6 +197,7 @@ class TelegramChannel(BaseChannel): # Add command handlers self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) + self._app.add_handler(CommandHandler("stop", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) # Add message handler for text, photos, voice, documents From 43fc59da0073f760b27221990cd5bc294682239f Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 14:53:14 +0000 Subject: [PATCH 086/113] fix: hide internal reasoning in progress --- nanobot/agent/loop.py | 16 +++------------ tests/test_message_tool_suppress.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 56a91c1..ca9a06e 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -202,19 +202,9 @@ class AgentLoop: if response.has_tool_calls: if on_progress: - # Only show stripped content and thinking blocks in progress, not reasoning_content - # reasoning_content is internal thinking and should not be shown to users - thoughts = [ - self._strip_think(response.content), - *( - f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}" - for b in (response.thinking_blocks or []) - if isinstance(b, dict) and "signature" in b - ), - ] - combined_thoughts = "\n\n".join(filter(None, thoughts)) - if combined_thoughts: - await on_progress(combined_thoughts) + thought = self._strip_think(response.content) + if thought: + await on_progress(thought) await on_progress(self._tool_hint(response.tool_calls), tool_hint=True) tool_call_dicts = [ diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py index 26b8a16..f5e65c9 100644 --- a/tests/test_message_tool_suppress.py +++ b/tests/test_message_tool_suppress.py @@ -86,6 +86,36 @@ class TestMessageToolSuppressLogic: assert result is not None assert "Hello" in result.content + @pytest.mark.asyncio + async def test_progress_hides_internal_reasoning(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + tool_call = ToolCallRequest(id="call1", name="read_file", arguments={"path": "foo.txt"}) + calls = iter([ + LLMResponse( + content="Visiblehidden", + tool_calls=[tool_call], + reasoning_content="secret reasoning", + thinking_blocks=[{"signature": "sig", "thought": "secret thought"}], + ), + LLMResponse(content="Done", tool_calls=[]), + ]) + loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls)) + loop.tools.get_definitions = MagicMock(return_value=[]) + loop.tools.execute = AsyncMock(return_value="ok") + + progress: list[tuple[str, bool]] = [] + + async def on_progress(content: str, *, tool_hint: bool = False) -> None: + progress.append((content, tool_hint)) + + final_content, _, _ = await loop._run_agent_loop([], on_progress=on_progress) + + assert final_content == "Done" + assert progress == [ + ("Visible", False), + ('read_file("foo.txt")', True), + ] + class TestMessageToolTurnTracking: From a9f3552d6e7cdb441c0bc376605d06d83ab5ee2a Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 15:11:09 +0000 Subject: [PATCH 087/113] test(telegram): cover proxy request initialization --- tests/test_telegram_channel.py | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 tests/test_telegram_channel.py diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py new file mode 100644 index 0000000..3bacf96 --- /dev/null +++ b/tests/test_telegram_channel.py @@ -0,0 +1,107 @@ +from types import SimpleNamespace + +import pytest + +from nanobot.bus.queue import MessageBus +from nanobot.channels.telegram import TelegramChannel +from nanobot.config.schema import TelegramConfig + + +class _FakeHTTPXRequest: + instances: list["_FakeHTTPXRequest"] = [] + + def __init__(self, **kwargs) -> None: + self.kwargs = kwargs + self.__class__.instances.append(self) + + +class _FakeUpdater: + def __init__(self, on_start_polling) -> None: + self._on_start_polling = on_start_polling + + async def start_polling(self, **kwargs) -> None: + self._on_start_polling() + + +class _FakeBot: + async def get_me(self): + return SimpleNamespace(username="nanobot_test") + + async def set_my_commands(self, commands) -> None: + self.commands = commands + + +class _FakeApp: + def __init__(self, on_start_polling) -> None: + self.bot = _FakeBot() + self.updater = _FakeUpdater(on_start_polling) + self.handlers = [] + self.error_handlers = [] + + def add_error_handler(self, handler) -> None: + self.error_handlers.append(handler) + + def add_handler(self, handler) -> None: + self.handlers.append(handler) + + async def initialize(self) -> None: + pass + + async def start(self) -> None: + pass + + +class _FakeBuilder: + def __init__(self, app: _FakeApp) -> None: + self.app = app + self.token_value = None + self.request_value = None + self.get_updates_request_value = None + + def token(self, token: str): + self.token_value = token + return self + + def request(self, request): + self.request_value = request + return self + + def get_updates_request(self, request): + self.get_updates_request_value = request + return self + + def proxy(self, _proxy): + raise AssertionError("builder.proxy should not be called when request is set") + + def get_updates_proxy(self, _proxy): + raise AssertionError("builder.get_updates_proxy should not be called when request is set") + + def build(self): + return self.app + + +@pytest.mark.asyncio +async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None: + config = TelegramConfig( + enabled=True, + token="123:abc", + allow_from=["*"], + proxy="http://127.0.0.1:7890", + ) + bus = MessageBus() + channel = TelegramChannel(config, bus) + app = _FakeApp(lambda: setattr(channel, "_running", False)) + builder = _FakeBuilder(app) + + monkeypatch.setattr("nanobot.channels.telegram.HTTPXRequest", _FakeHTTPXRequest) + monkeypatch.setattr( + "nanobot.channels.telegram.Application", + SimpleNamespace(builder=lambda: builder), + ) + + await channel.start() + + assert len(_FakeHTTPXRequest.instances) == 1 + assert _FakeHTTPXRequest.instances[0].kwargs["proxy"] == config.proxy + assert builder.request_value is _FakeHTTPXRequest.instances[0] + assert builder.get_updates_request_value is _FakeHTTPXRequest.instances[0] From 26670d3e8042746e4ce0feaaa3761aae7a97b436 Mon Sep 17 00:00:00 2001 From: shawn_wxn Date: Fri, 6 Mar 2026 19:08:44 +0800 Subject: [PATCH 088/113] feat(dingtalk): add support for group chat messages --- nanobot/channels/dingtalk.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 8d02fa6..8e2a2be 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -70,6 +70,13 @@ class NanobotDingTalkHandler(CallbackHandler): sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_name = chatbot_msg.sender_nick or "Unknown" + # Extract conversation info + conversation_type = message.data.get("conversationType") + conversation_id = message.data.get("conversationId") or message.data.get("openConversationId") + + if conversation_type == "2" and conversation_id: + sender_id = f"group:{conversation_id}" + logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) # Forward to Nanobot via _on_message (non-blocking). @@ -301,14 +308,25 @@ class DingTalkChannel(BaseChannel): logger.warning("DingTalk HTTP client not initialized, cannot send") return False - url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" headers = {"x-acs-dingtalk-access-token": token} - payload = { - "robotCode": self.config.client_id, - "userIds": [chat_id], - "msgKey": msg_key, - "msgParam": json.dumps(msg_param, ensure_ascii=False), - } + if chat_id.startswith("group:"): + # Group chat + url = "https://api.dingtalk.com/v1.0/robot/groupMessages/send" + payload = { + "robotCode": self.config.client_id, + "openConversationId": chat_id[6:], # Remove "group:" prefix, + "msgKey": "sampleMarkdown", + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } + else: + # Private chat + url = "https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend" + payload = { + "robotCode": self.config.client_id, + "userIds": [chat_id], + "msgKey": msg_key, + "msgParam": json.dumps(msg_param, ensure_ascii=False), + } try: resp = await self._http.post(url, json=payload, headers=headers) From caa2aa596dbaabb121af618e00635d47d1126f02 Mon Sep 17 00:00:00 2001 From: shawn_wxn Date: Fri, 6 Mar 2026 19:08:59 +0800 Subject: [PATCH 089/113] fix(dingtalk): correct msgKey parameter for group messages --- nanobot/channels/dingtalk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 8e2a2be..bd6a8c2 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -315,7 +315,7 @@ class DingTalkChannel(BaseChannel): payload = { "robotCode": self.config.client_id, "openConversationId": chat_id[6:], # Remove "group:" prefix, - "msgKey": "sampleMarkdown", + "msgKey": "msg_key", "msgParam": json.dumps(msg_param, ensure_ascii=False), } else: From 73991779b3bc82dcd39c0b9b6b189577380c7b1a Mon Sep 17 00:00:00 2001 From: shawn_wxn Date: Fri, 6 Mar 2026 19:58:22 +0800 Subject: [PATCH 090/113] fix(dingtalk): use msg_key variable instead of hardcoded --- nanobot/channels/dingtalk.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index bd6a8c2..78ca6c9 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -315,7 +315,7 @@ class DingTalkChannel(BaseChannel): payload = { "robotCode": self.config.client_id, "openConversationId": chat_id[6:], # Remove "group:" prefix, - "msgKey": "msg_key", + "msgKey": msg_key, "msgParam": json.dumps(msg_param, ensure_ascii=False), } else: From 4e25ac5c82f8210bb4acf18bf0abd9e5f47841d2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 16:07:57 +0000 Subject: [PATCH 091/113] test(dingtalk): cover group reply routing --- nanobot/channels/dingtalk.py | 35 ++++++++++++------ tests/test_dingtalk_channel.py | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 tests/test_dingtalk_channel.py diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 78ca6c9..3c301a9 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -70,19 +70,24 @@ class NanobotDingTalkHandler(CallbackHandler): sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_name = chatbot_msg.sender_nick or "Unknown" - # Extract conversation info conversation_type = message.data.get("conversationType") - conversation_id = message.data.get("conversationId") or message.data.get("openConversationId") - - if conversation_type == "2" and conversation_id: - sender_id = f"group:{conversation_id}" + conversation_id = ( + message.data.get("conversationId") + or message.data.get("openConversationId") + ) logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) # 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._on_message( + content, + sender_id, + sender_name, + conversation_type, + conversation_id, + ) ) self.channel._background_tasks.add(task) task.add_done_callback(self.channel._background_tasks.discard) @@ -102,8 +107,8 @@ class DingTalkChannel(BaseChannel): Uses WebSocket to receive events via `dingtalk-stream` SDK. 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. + Supports both private (1:1) and group chats. + Group chat_id is stored with a "group:" prefix to route replies back. """ name = "dingtalk" @@ -435,7 +440,14 @@ class DingTalkChannel(BaseChannel): f"[Attachment send failed: {filename}]", ) - async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: + async def _on_message( + self, + content: str, + sender_id: str, + sender_name: str, + conversation_type: str | None = None, + conversation_id: str | None = None, + ) -> None: """Handle incoming message (called by NanobotDingTalkHandler). Delegates to BaseChannel._handle_message() which enforces allow_from @@ -443,13 +455,16 @@ class DingTalkChannel(BaseChannel): """ try: logger.info("DingTalk inbound: {} from {}", content, sender_name) + is_group = conversation_type == "2" and conversation_id + chat_id = f"group:{conversation_id}" if is_group else sender_id await self._handle_message( sender_id=sender_id, - chat_id=sender_id, # For private chat, chat_id == sender_id + chat_id=chat_id, content=str(content), metadata={ "sender_name": sender_name, "platform": "dingtalk", + "conversation_type": conversation_type, }, ) except Exception as e: diff --git a/tests/test_dingtalk_channel.py b/tests/test_dingtalk_channel.py new file mode 100644 index 0000000..7595a33 --- /dev/null +++ b/tests/test_dingtalk_channel.py @@ -0,0 +1,66 @@ +from types import SimpleNamespace + +import pytest + +from nanobot.bus.queue import MessageBus +from nanobot.channels.dingtalk import DingTalkChannel +from nanobot.config.schema import DingTalkConfig + + +class _FakeResponse: + def __init__(self, status_code: int = 200, json_body: dict | None = None) -> None: + self.status_code = status_code + self._json_body = json_body or {} + self.text = "{}" + + def json(self) -> dict: + return self._json_body + + +class _FakeHttp: + def __init__(self) -> None: + self.calls: list[dict] = [] + + async def post(self, url: str, json=None, headers=None): + self.calls.append({"url": url, "json": json, "headers": headers}) + return _FakeResponse() + + +@pytest.mark.asyncio +async def test_group_message_keeps_sender_id_and_routes_chat_id() -> None: + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["user1"]) + bus = MessageBus() + channel = DingTalkChannel(config, bus) + + await channel._on_message( + "hello", + sender_id="user1", + sender_name="Alice", + conversation_type="2", + conversation_id="conv123", + ) + + msg = await bus.consume_inbound() + assert msg.sender_id == "user1" + assert msg.chat_id == "group:conv123" + assert msg.metadata["conversation_type"] == "2" + + +@pytest.mark.asyncio +async def test_group_send_uses_group_messages_api() -> None: + config = DingTalkConfig(client_id="app", client_secret="secret", allow_from=["*"]) + channel = DingTalkChannel(config, MessageBus()) + channel._http = _FakeHttp() + + ok = await channel._send_batch_message( + "token", + "group:conv123", + "sampleMarkdown", + {"text": "hello", "title": "Nanobot Reply"}, + ) + + assert ok is True + call = channel._http.calls[0] + assert call["url"] == "https://api.dingtalk.com/v1.0/robot/groupMessages/send" + assert call["json"]["openConversationId"] == "conv123" + assert call["json"]["msgKey"] == "sampleMarkdown" From 057927cd24871c73ecd46e47291ec0aa4ac3a2ce Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sat, 7 Mar 2026 16:36:12 +0000 Subject: [PATCH 092/113] fix(auth): prevent allowlist bypass via sender_id token splitting --- nanobot/channels/base.py | 5 +---- nanobot/channels/telegram.py | 19 +++++++++++++++++++ tests/test_base_channel.py | 25 +++++++++++++++++++++++++ tests/test_telegram_channel.py | 15 +++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 tests/test_base_channel.py diff --git a/nanobot/channels/base.py b/nanobot/channels/base.py index b38fcaf..dc53ba4 100644 --- a/nanobot/channels/base.py +++ b/nanobot/channels/base.py @@ -66,10 +66,7 @@ class BaseChannel(ABC): return False if "*" in allow_list: return True - sender_str = str(sender_id) - return sender_str in allow_list or any( - p in allow_list for p in sender_str.split("|") if p - ) + return str(sender_id) in allow_list async def _handle_message( self, diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 81cf0ca..501a3c1 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -179,6 +179,25 @@ class TelegramChannel(BaseChannel): self._media_group_tasks: dict[str, asyncio.Task] = {} self._message_threads: dict[tuple[str, int], int] = {} + def is_allowed(self, sender_id: str) -> bool: + """Preserve Telegram's legacy id|username allowlist matching.""" + if super().is_allowed(sender_id): + return True + + allow_list = getattr(self.config, "allow_from", []) + if not allow_list or "*" in allow_list: + return False + + sender_str = str(sender_id) + if sender_str.count("|") != 1: + return False + + sid, username = sender_str.split("|", 1) + if not sid.isdigit() or not username: + return False + + return sid in allow_list or username in allow_list + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: diff --git a/tests/test_base_channel.py b/tests/test_base_channel.py new file mode 100644 index 0000000..5d10d4e --- /dev/null +++ b/tests/test_base_channel.py @@ -0,0 +1,25 @@ +from types import SimpleNamespace + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.base import BaseChannel + + +class _DummyChannel(BaseChannel): + name = "dummy" + + async def start(self) -> None: + return None + + async def stop(self) -> None: + return None + + async def send(self, msg: OutboundMessage) -> None: + return None + + +def test_is_allowed_requires_exact_match() -> None: + channel = _DummyChannel(SimpleNamespace(allow_from=["allow@email.com"]), MessageBus()) + + assert channel.is_allowed("allow@email.com") is True + assert channel.is_allowed("attacker|allow@email.com") is False diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index acd2a96..88c3f54 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -131,6 +131,21 @@ def test_get_extension_falls_back_to_original_filename() -> None: assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz" +def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None: + channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus()) + + assert channel.is_allowed("12345|carol") is True + assert channel.is_allowed("99999|alice") is True + assert channel.is_allowed("67890|bob") is True + + +def test_is_allowed_rejects_invalid_legacy_telegram_sender_shapes() -> None: + channel = TelegramChannel(TelegramConfig(allow_from=["alice"]), MessageBus()) + + assert channel.is_allowed("attacker|alice|extra") is False + assert channel.is_allowed("not-a-number|alice") is False + + @pytest.mark.asyncio async def test_send_progress_keeps_message_in_topic() -> None: config = TelegramConfig(enabled=True, token="123:abc", allow_from=["*"]) From 3ca89d7821a0eccfc4e66b11e511fd4565c4f6b1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 01:42:30 +0000 Subject: [PATCH 093/113] docs: update nanobot news --- README.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 03f042a..3c20adb 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,20 @@ ## ๐Ÿ“ข News +- **2026-03-07** ๐Ÿš€ Azure OpenAI, WhatsApp media, Discord attachments, QQ group chats, and lots of Telegram/Feishu polish. +- **2026-03-06** ๐Ÿช„ Lighter provider loading, smarter message/media handling, and more robust memory and CLI compatibility. +- **2026-03-05** โšก๏ธ Telegram draft streaming, MCP SSE support, multi-instance gateway runs, and broader channel reliability fixes. +- **2026-03-04** ๐Ÿ› ๏ธ Dependency cleanup, safer file reads, and a fresh round of test, Cron, and validation reliability fixes. +- **2026-03-03** ๐Ÿง  Cleaner user-message merging, safer multimodal session saves, and stronger Cron scheduling guards. +- **2026-03-02** ๐Ÿ›ก๏ธ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling. +- **2026-03-01** ๐ŸŒ Web proxy support, smarter Cron reminders, Feishu rich-text parsing, and more cleanup across the codebase. - **2026-02-28** ๐Ÿš€ Released **v0.1.4.post3** โ€” cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. - **2026-02-27** ๐Ÿง  Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. - **2026-02-26** ๐Ÿ›ก๏ธ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. + +
+Earlier news + - **2026-02-25** ๐Ÿงน New Matrix channel, cleaner session context, auto workspace template sync. - **2026-02-24** ๐Ÿš€ Released **v0.1.4.post2** โ€” a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details. - **2026-02-23** ๐Ÿ”ง Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes. @@ -30,10 +41,6 @@ - **2026-02-21** ๐ŸŽ‰ Released **v0.1.4.post1** โ€” new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details. - **2026-02-20** ๐Ÿฆ Feishu now receives multimodal files from users. More reliable memory under the hood. - **2026-02-19** โœจ Slack now sends files, Discord splits long messages, and subagents work in CLI mode. - -
-Earlier news - - **2026-02-18** โšก๏ธ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching. - **2026-02-17** ๐ŸŽ‰ Released **v0.1.4** โ€” MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details. - **2026-02-16** ๐Ÿฆž nanobot now integrates a [ClawHub](https://clawhub.ai) skill โ€” search and install public agent skills. From 822d2311e0c4eee4a51fe2c62a89bc543f027458 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 01:44:06 +0000 Subject: [PATCH 094/113] docs: update nanobot march news --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3c20adb..18770dc 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ ## ๐Ÿ“ข News -- **2026-03-07** ๐Ÿš€ Azure OpenAI, WhatsApp media, Discord attachments, QQ group chats, and lots of Telegram/Feishu polish. -- **2026-03-06** ๐Ÿช„ Lighter provider loading, smarter message/media handling, and more robust memory and CLI compatibility. -- **2026-03-05** โšก๏ธ Telegram draft streaming, MCP SSE support, multi-instance gateway runs, and broader channel reliability fixes. -- **2026-03-04** ๐Ÿ› ๏ธ Dependency cleanup, safer file reads, and a fresh round of test, Cron, and validation reliability fixes. -- **2026-03-03** ๐Ÿง  Cleaner user-message merging, safer multimodal session saves, and stronger Cron scheduling guards. +- **2026-03-07** ๐Ÿš€ Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. +- **2026-03-06** ๐Ÿช„ Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. +- **2026-03-05** โšก๏ธ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. +- **2026-03-04** ๐Ÿ› ๏ธ Dependency cleanup, safer file reads, and another round of test and Cron fixes. +- **2026-03-03** ๐Ÿง  Cleaner user-message merging, safer multimodal saves, and stronger Cron guards. - **2026-03-02** ๐Ÿ›ก๏ธ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling. -- **2026-03-01** ๐ŸŒ Web proxy support, smarter Cron reminders, Feishu rich-text parsing, and more cleanup across the codebase. +- **2026-03-01** ๐ŸŒ Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements. - **2026-02-28** ๐Ÿš€ Released **v0.1.4.post3** โ€” cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details. - **2026-02-27** ๐Ÿง  Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes. - **2026-02-26** ๐Ÿ›ก๏ธ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility. From 20dfaa5d34968cf8d3f19a180e053f145a7dfad3 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 02:58:25 +0000 Subject: [PATCH 095/113] refactor: unify instance path resolution and preserve workspace override --- README.md | 192 ++++++----------------------------- bridge/src/whatsapp.ts | 3 +- nanobot/channels/discord.py | 3 +- nanobot/channels/feishu.py | 4 +- nanobot/channels/matrix.py | 6 +- nanobot/channels/mochat.py | 4 +- nanobot/channels/telegram.py | 6 +- nanobot/cli/commands.py | 27 +++-- nanobot/config/__init__.py | 26 ++++- nanobot/config/loader.py | 8 -- nanobot/config/paths.py | 55 ++++++++++ nanobot/session/manager.py | 3 +- nanobot/utils/__init__.py | 4 +- nanobot/utils/helpers.py | 12 --- tests/test_commands.py | 97 +++++++++++++++++- tests/test_config_paths.py | 42 ++++++++ 16 files changed, 282 insertions(+), 210 deletions(-) create mode 100644 nanobot/config/paths.py create mode 100644 tests/test_config_paths.py diff --git a/README.md b/README.md index fdbd5cf..5bd11f8 100644 --- a/README.md +++ b/README.md @@ -905,7 +905,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us ## Multiple Instances -Run multiple nanobot instances simultaneously with complete isolation. Each instance has its own configuration, workspace, cron jobs, logs, and media storage. +Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run. ### Quick Start @@ -920,35 +920,31 @@ nanobot gateway --config ~/.nanobot-discord/config.json nanobot gateway --config ~/.nanobot-feishu/config.json --port 18792 ``` -### Complete Isolation +### Path Resolution -When using `--config` parameter, nanobot automatically derives the data directory from the config file path, ensuring complete isolation: +When using `--config`, nanobot derives its runtime data directory from the config file location. The workspace still comes from `agents.defaults.workspace` unless you override it with `--workspace`. -| Component | Isolation | Example | -|-----------|-----------|---------| -| **Config** | Separate config files | `~/.nanobot-A/config.json`, `~/.nanobot-B/config.json` | -| **Workspace** | Independent memory, sessions, skills | `~/.nanobot-A/workspace/`, `~/.nanobot-B/workspace/` | -| **Cron Jobs** | Separate job storage | `~/.nanobot-A/cron/`, `~/.nanobot-B/cron/` | -| **Logs** | Independent log files | `~/.nanobot-A/logs/`, `~/.nanobot-B/logs/` | -| **Media** | Separate media storage | `~/.nanobot-A/media/`, `~/.nanobot-B/media/` | +| Component | Resolved From | Example | +|-----------|---------------|---------| +| **Config** | `--config` path | `~/.nanobot-A/config.json` | +| **Workspace** | `--workspace` or config | `~/.nanobot-A/workspace/` | +| **Cron Jobs** | config directory | `~/.nanobot-A/cron/` | +| **Media / runtime state** | config directory | `~/.nanobot-A/media/` | -### Setup Example +### How It Works -**1. Create directory structure for each instance:** +- `--config` selects which config file to load +- By default, the workspace comes from `agents.defaults.workspace` in that config +- If you pass `--workspace`, it overrides the workspace from the config file -```bash -# Instance A -mkdir -p ~/.nanobot-telegram/{workspace,cron,logs,media} -cp ~/.nanobot/config.json ~/.nanobot-telegram/config.json +### Minimal Setup -# Instance B -mkdir -p ~/.nanobot-discord/{workspace,cron,logs,media} -cp ~/.nanobot/config.json ~/.nanobot-discord/config.json -``` +1. Copy your base config into a new instance directory. +2. Set a different `agents.defaults.workspace` for that instance. +3. Start the instance with `--config`. -**2. Configure each instance:** +Example config: -Edit `~/.nanobot-telegram/config.json`: ```json { "agents": { @@ -969,160 +965,32 @@ Edit `~/.nanobot-telegram/config.json`: } ``` -Edit `~/.nanobot-discord/config.json`: -```json -{ - "agents": { - "defaults": { - "workspace": "~/.nanobot-discord/workspace", - "model": "anthropic/claude-opus-4" - } - }, - "channels": { - "discord": { - "enabled": true, - "token": "YOUR_DISCORD_BOT_TOKEN" - } - }, - "gateway": { - "port": 18791 - } -} -``` - -**3. Start instances:** +Start separate instances: ```bash -# Terminal 1 nanobot gateway --config ~/.nanobot-telegram/config.json - -# Terminal 2 nanobot gateway --config ~/.nanobot-discord/config.json ``` -### Use Cases - -- **Multiple Chat Platforms**: Run separate bots for Telegram, Discord, Feishu, etc. -- **Different Models**: Test different LLM models (Claude, GPT, DeepSeek) simultaneously -- **Role Separation**: Dedicated instances for different purposes (personal assistant, work bot, research agent) -- **Multi-Tenant**: Serve multiple users/teams with isolated configurations - -### Management Scripts - -For production deployments, create management scripts for each instance: +Override workspace for one-off runs when needed: ```bash -#!/bin/bash -# manage-telegram.sh - -INSTANCE_NAME="telegram" -CONFIG_FILE="$HOME/.nanobot-telegram/config.json" -LOG_FILE="$HOME/.nanobot-telegram/logs/stderr.log" - -case "$1" in - start) - nohup nanobot gateway --config "$CONFIG_FILE" >> "$LOG_FILE" 2>&1 & - echo "Started $INSTANCE_NAME instance (PID: $!)" - ;; - stop) - pkill -f "nanobot gateway.*$CONFIG_FILE" - echo "Stopped $INSTANCE_NAME instance" - ;; - restart) - $0 stop - sleep 2 - $0 start - ;; - status) - pgrep -f "nanobot gateway.*$CONFIG_FILE" > /dev/null - if [ $? -eq 0 ]; then - echo "$INSTANCE_NAME instance is running" - else - echo "$INSTANCE_NAME instance is not running" - fi - ;; - *) - echo "Usage: $0 {start|stop|restart|status}" - exit 1 - ;; -esac +nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobot-telegram-test ``` -### systemd Service (Linux) +### Common Use Cases -For automatic startup and crash recovery, create a systemd service for each instance: - -```ini -# ~/.config/systemd/user/nanobot-telegram.service -[Unit] -Description=Nanobot Telegram Instance -After=network.target - -[Service] -Type=simple -ExecStart=%h/.local/bin/nanobot gateway --config %h/.nanobot-telegram/config.json -Restart=always -RestartSec=10 - -[Install] -WantedBy=default.target -``` - -Enable and start: -```bash -systemctl --user daemon-reload -systemctl --user enable --now nanobot-telegram -systemctl --user enable --now nanobot-discord -``` - -### launchd Service (macOS) - -Create a plist file for each instance: - -```xml - - - - - - Label - com.nanobot.telegram - - ProgramArguments - - /path/to/nanobot - gateway - --config - /Users/yourname/.nanobot-telegram/config.json - - - RunAtLoad - - - KeepAlive - - - StandardOutPath - /Users/yourname/.nanobot-telegram/logs/stdout.log - - StandardErrorPath - /Users/yourname/.nanobot-telegram/logs/stderr.log - - -``` - -Load the service: -```bash -launchctl load ~/Library/LaunchAgents/com.nanobot.telegram.plist -launchctl load ~/Library/LaunchAgents/com.nanobot.discord.plist -``` +- Run separate bots for Telegram, Discord, Feishu, and other platforms +- Keep testing and production instances isolated +- Use different models or providers for different teams +- Serve multiple tenants with separate configs and runtime data ### Notes -- Each instance must use a different port (default: 18790) -- Instances are completely independent โ€” no shared state or cross-talk -- You can run different LLM models, providers, and channel configurations per instance -- Memory, sessions, and cron jobs are fully isolated between instances +- Each instance must use a different port if they run at the same time +- Use a different workspace per instance if you want isolated memory, sessions, and skills +- `--workspace` overrides the workspace defined in the config file +- Cron jobs and runtime media/state are derived from the config directory ## CLI Reference diff --git a/bridge/src/whatsapp.ts b/bridge/src/whatsapp.ts index b91bacc..f0485bd 100644 --- a/bridge/src/whatsapp.ts +++ b/bridge/src/whatsapp.ts @@ -18,7 +18,6 @@ import qrcode from 'qrcode-terminal'; import pino from 'pino'; import { writeFile, mkdir } from 'fs/promises'; import { join } from 'path'; -import { homedir } from 'os'; import { randomBytes } from 'crypto'; const VERSION = '0.1.0'; @@ -162,7 +161,7 @@ export class WhatsAppClient { private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise { try { - const mediaDir = join(homedir(), '.nanobot', 'media'); + const mediaDir = join(this.options.authDir, '..', 'media'); await mkdir(mediaDir, { recursive: true }); const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer; diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 0187c62..2ee4f77 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -12,6 +12,7 @@ from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import DiscordConfig from nanobot.utils.helpers import split_message @@ -289,7 +290,7 @@ class DiscordChannel(BaseChannel): content_parts = [content] if content else [] media_paths: list[str] = [] - media_dir = Path.home() / ".nanobot" / "media" + media_dir = get_media_dir("discord") for attachment in payload.get("attachments") or []: url = attachment.get("url") diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 2dcf710..a637025 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -14,6 +14,7 @@ from loguru import logger from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import FeishuConfig import importlib.util @@ -732,8 +733,7 @@ class FeishuChannel(BaseChannel): (file_path, content_text) - file_path is None if download failed """ loop = asyncio.get_running_loop() - media_dir = Path.home() / ".nanobot" / "media" - media_dir.mkdir(parents=True, exist_ok=True) + media_dir = get_media_dir("feishu") data, filename = None, None diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py index 4967ac1..63cb0ca 100644 --- a/nanobot/channels/matrix.py +++ b/nanobot/channels/matrix.py @@ -38,7 +38,7 @@ except ImportError as e: from nanobot.bus.events import OutboundMessage from nanobot.channels.base import BaseChannel -from nanobot.config.loader import get_data_dir +from nanobot.config.paths import get_data_dir, get_media_dir from nanobot.utils.helpers import safe_filename TYPING_NOTICE_TIMEOUT_MS = 30_000 @@ -490,9 +490,7 @@ class MatrixChannel(BaseChannel): return False def _media_dir(self) -> Path: - d = get_data_dir() / "media" / "matrix" - d.mkdir(parents=True, exist_ok=True) - return d + return get_media_dir("matrix") @staticmethod def _event_source_content(event: RoomMessage) -> dict[str, Any]: diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index e762dfd..09e31c3 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -15,8 +15,8 @@ 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.paths import get_runtime_subdir from nanobot.config.schema import MochatConfig -from nanobot.utils.helpers import get_data_path try: import socketio @@ -224,7 +224,7 @@ class MochatChannel(BaseChannel): self._socket: Any = None self._ws_connected = self._ws_ready = False - self._state_dir = get_data_path() / "mochat" + self._state_dir = get_runtime_subdir("mochat") self._cursor_path = self._state_dir / "session_cursors.json" self._session_cursor: dict[str, int] = {} self._cursor_save_task: asyncio.Task | None = None diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 501a3c1..ecb1440 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -15,6 +15,7 @@ from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.config.paths import get_media_dir from nanobot.config.schema import TelegramConfig from nanobot.utils.helpers import split_message @@ -536,10 +537,7 @@ class TelegramChannel(BaseChannel): getattr(media_file, 'mime_type', None), getattr(media_file, 'file_name', None), ) - # Save to workspace/media/ - from pathlib import Path - media_dir = Path.home() / ".nanobot" / "media" - media_dir.mkdir(parents=True, exist_ok=True) + media_dir = get_media_dir("telegram") file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 47c9a30..da8906d 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -30,6 +30,7 @@ from rich.table import Table from rich.text import Text from nanobot import __logo__, __version__ +from nanobot.config.paths import get_workspace_path from nanobot.config.schema import Config from nanobot.utils.helpers import sync_workspace_templates @@ -99,7 +100,9 @@ def _init_prompt_session() -> None: except Exception: pass - history_file = Path.home() / ".nanobot" / "history" / "cli_history" + from nanobot.config.paths import get_cli_history_path + + history_file = get_cli_history_path() history_file.parent.mkdir(parents=True, exist_ok=True) _PROMPT_SESSION = PromptSession( @@ -170,7 +173,6 @@ def onboard(): """Initialize nanobot configuration and workspace.""" from nanobot.config.loader import get_config_path, load_config, save_config from nanobot.config.schema import Config - from nanobot.utils.helpers import get_workspace_path config_path = get_config_path() @@ -271,8 +273,9 @@ def _make_provider(config: Config): @app.command() def gateway( port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), - config: str = typer.Option(None, "--config", "-c", help="Path to config file"), + config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Start the nanobot gateway.""" # Set config path if provided (must be done before any imports that use get_data_dir) @@ -288,7 +291,8 @@ def gateway( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager - from nanobot.config.loader import get_data_dir, load_config + from nanobot.config.loader import load_config + from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService from nanobot.cron.types import CronJob from nanobot.heartbeat.service import HeartbeatService @@ -301,13 +305,15 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() + if workspace: + config.agents.defaults.workspace = workspace sync_workspace_templates(config.workspace_path) 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_store_path = get_cron_dir() / "jobs.json" cron = CronService(cron_store_path) # Create agent with cron service @@ -476,7 +482,8 @@ def agent( from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus - from nanobot.config.loader import get_data_dir, load_config + from nanobot.config.loader import load_config + from nanobot.config.paths import get_cron_dir from nanobot.cron.service import CronService config = load_config() @@ -486,7 +493,7 @@ def agent( provider = _make_provider(config) # Create cron service for tool usage (no callback needed for CLI unless running) - cron_store_path = get_data_dir() / "cron" / "jobs.json" + cron_store_path = get_cron_dir() / "jobs.json" cron = CronService(cron_store_path) if logs: @@ -752,7 +759,9 @@ def _get_bridge_dir() -> Path: import subprocess # User's bridge location - user_bridge = Path.home() / ".nanobot" / "bridge" + from nanobot.config.paths import get_bridge_install_dir + + user_bridge = get_bridge_install_dir() # Check if already built if (user_bridge / "dist" / "index.js").exists(): @@ -810,6 +819,7 @@ def channels_login(): import subprocess from nanobot.config.loader import load_config + from nanobot.config.paths import get_runtime_subdir config = load_config() bridge_dir = _get_bridge_dir() @@ -820,6 +830,7 @@ def channels_login(): env = {**os.environ} if config.channels.whatsapp.bridge_token: env["BRIDGE_TOKEN"] = config.channels.whatsapp.bridge_token + env["AUTH_DIR"] = str(get_runtime_subdir("whatsapp-auth")) try: subprocess.run(["npm", "start"], cwd=bridge_dir, check=True, env=env) diff --git a/nanobot/config/__init__.py b/nanobot/config/__init__.py index 6c59668..e2c24f8 100644 --- a/nanobot/config/__init__.py +++ b/nanobot/config/__init__.py @@ -1,6 +1,30 @@ """Configuration module for nanobot.""" from nanobot.config.loader import get_config_path, load_config +from nanobot.config.paths import ( + get_bridge_install_dir, + get_cli_history_path, + get_cron_dir, + get_data_dir, + get_legacy_sessions_dir, + get_logs_dir, + get_media_dir, + get_runtime_subdir, + get_workspace_path, +) from nanobot.config.schema import Config -__all__ = ["Config", "load_config", "get_config_path"] +__all__ = [ + "Config", + "load_config", + "get_config_path", + "get_data_dir", + "get_runtime_subdir", + "get_media_dir", + "get_cron_dir", + "get_logs_dir", + "get_workspace_path", + "get_cli_history_path", + "get_bridge_install_dir", + "get_legacy_sessions_dir", +] diff --git a/nanobot/config/loader.py b/nanobot/config/loader.py index 4355bd3..7d309e5 100644 --- a/nanobot/config/loader.py +++ b/nanobot/config/loader.py @@ -23,14 +23,6 @@ def get_config_path() -> Path: return Path.home() / ".nanobot" / "config.json" -def get_data_dir() -> Path: - """Get the nanobot data directory (derived from config path).""" - config_path = get_config_path() - # If config is ~/.nanobot-xxx/config.json, data dir is ~/.nanobot-xxx/ - # If config is ~/.nanobot/config.json, data dir is ~/.nanobot/ - return config_path.parent - - def load_config(config_path: Path | None = None) -> Config: """ Load configuration from file or create default. diff --git a/nanobot/config/paths.py b/nanobot/config/paths.py new file mode 100644 index 0000000..f4dfbd9 --- /dev/null +++ b/nanobot/config/paths.py @@ -0,0 +1,55 @@ +"""Runtime path helpers derived from the active config context.""" + +from __future__ import annotations + +from pathlib import Path + +from nanobot.config.loader import get_config_path +from nanobot.utils.helpers import ensure_dir + + +def get_data_dir() -> Path: + """Return the instance-level runtime data directory.""" + return ensure_dir(get_config_path().parent) + + +def get_runtime_subdir(name: str) -> Path: + """Return a named runtime subdirectory under the instance data dir.""" + return ensure_dir(get_data_dir() / name) + + +def get_media_dir(channel: str | None = None) -> Path: + """Return the media directory, optionally namespaced per channel.""" + base = get_runtime_subdir("media") + return ensure_dir(base / channel) if channel else base + + +def get_cron_dir() -> Path: + """Return the cron storage directory.""" + return get_runtime_subdir("cron") + + +def get_logs_dir() -> Path: + """Return the logs directory.""" + return get_runtime_subdir("logs") + + +def get_workspace_path(workspace: str | None = None) -> Path: + """Resolve and ensure the agent workspace path.""" + path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" + return ensure_dir(path) + + +def get_cli_history_path() -> Path: + """Return the shared CLI history file path.""" + return Path.home() / ".nanobot" / "history" / "cli_history" + + +def get_bridge_install_dir() -> Path: + """Return the shared WhatsApp bridge installation directory.""" + return Path.home() / ".nanobot" / "bridge" + + +def get_legacy_sessions_dir() -> Path: + """Return the legacy global session directory used for migration fallback.""" + return Path.home() / ".nanobot" / "sessions" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index dce4b2e..f0a6484 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -9,6 +9,7 @@ from typing import Any from loguru import logger +from nanobot.config.paths import get_legacy_sessions_dir from nanobot.utils.helpers import ensure_dir, safe_filename @@ -79,7 +80,7 @@ class SessionManager: def __init__(self, workspace: Path): self.workspace = workspace self.sessions_dir = ensure_dir(self.workspace / "sessions") - self.legacy_sessions_dir = Path.home() / ".nanobot" / "sessions" + self.legacy_sessions_dir = get_legacy_sessions_dir() self._cache: dict[str, Session] = {} def _get_session_path(self, key: str) -> Path: diff --git a/nanobot/utils/__init__.py b/nanobot/utils/__init__.py index 9163e38..46f02ac 100644 --- a/nanobot/utils/__init__.py +++ b/nanobot/utils/__init__.py @@ -1,5 +1,5 @@ """Utility functions for nanobot.""" -from nanobot.utils.helpers import ensure_dir, get_data_path, get_workspace_path +from nanobot.utils.helpers import ensure_dir -__all__ = ["ensure_dir", "get_workspace_path", "get_data_path"] +__all__ = ["ensure_dir"] diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 6e8ecd5..57c60dc 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -24,18 +24,6 @@ def ensure_dir(path: Path) -> Path: return path -def get_data_path() -> Path: - """Get nanobot data directory (derived from config path).""" - from nanobot.config.loader import get_data_dir - return ensure_dir(get_data_dir()) - - -def get_workspace_path(workspace: str | None = None) -> Path: - """Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace.""" - path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" - return ensure_dir(path) - - def timestamp() -> str: """Current ISO timestamp.""" return datetime.now().isoformat() diff --git a/tests/test_commands.py b/tests/test_commands.py index 044d113..a276653 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -14,13 +14,17 @@ from nanobot.providers.registry import find_by_model runner = CliRunner() +class _StopGateway(RuntimeError): + pass + + @pytest.fixture def mock_paths(): """Mock config/workspace paths for test isolation.""" with patch("nanobot.config.loader.get_config_path") as mock_cp, \ patch("nanobot.config.loader.save_config") as mock_sc, \ patch("nanobot.config.loader.load_config") as mock_lc, \ - patch("nanobot.utils.helpers.get_workspace_path") as mock_ws: + patch("nanobot.cli.commands.get_workspace_path") as mock_ws: base_dir = Path("./test_onboard_data") if base_dir.exists(): @@ -128,3 +132,94 @@ def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix(): def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" + + +def test_gateway_uses_workspace_from_config_by_default(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + seen: dict[str, Path] = {} + + monkeypatch.setattr( + "nanobot.config.loader.set_config_path", + lambda path: seen.__setitem__("config_path", path), + ) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + monkeypatch.setattr( + "nanobot.cli.commands.sync_workspace_templates", + lambda path: seen.__setitem__("workspace", path), + ) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert seen["config_path"] == config_file.resolve() + assert seen["workspace"] == Path(config.agents.defaults.workspace) + + +def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + override = tmp_path / "override-workspace" + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + monkeypatch.setattr( + "nanobot.cli.commands.sync_workspace_templates", + lambda path: seen.__setitem__("workspace", path), + ) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke( + app, + ["gateway", "--config", str(config_file), "--workspace", str(override)], + ) + + assert isinstance(result.exception, _StopGateway) + assert seen["workspace"] == override + assert config.workspace_path == override + + +def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.agents.defaults.workspace = str(tmp_path / "config-workspace") + seen: dict[str, Path] = {} + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda: config) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.session.manager.SessionManager", lambda _workspace: object()) + + class _StopCron: + def __init__(self, store_path: Path) -> None: + seen["cron_store"] = store_path + raise _StopGateway("stop") + + monkeypatch.setattr("nanobot.cron.service.CronService", _StopCron) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" diff --git a/tests/test_config_paths.py b/tests/test_config_paths.py new file mode 100644 index 0000000..473a6c8 --- /dev/null +++ b/tests/test_config_paths.py @@ -0,0 +1,42 @@ +from pathlib import Path + +from nanobot.config.paths import ( + get_bridge_install_dir, + get_cli_history_path, + get_cron_dir, + get_data_dir, + get_legacy_sessions_dir, + get_logs_dir, + get_media_dir, + get_runtime_subdir, + get_workspace_path, +) + + +def test_runtime_dirs_follow_config_path(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance-a" / "config.json" + monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file) + + assert get_data_dir() == config_file.parent + assert get_runtime_subdir("cron") == config_file.parent / "cron" + assert get_cron_dir() == config_file.parent / "cron" + assert get_logs_dir() == config_file.parent / "logs" + + +def test_media_dir_supports_channel_namespace(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance-b" / "config.json" + monkeypatch.setattr("nanobot.config.paths.get_config_path", lambda: config_file) + + assert get_media_dir() == config_file.parent / "media" + assert get_media_dir("telegram") == config_file.parent / "media" / "telegram" + + +def test_shared_and_legacy_paths_remain_global() -> None: + assert get_cli_history_path() == Path.home() / ".nanobot" / "history" / "cli_history" + assert get_bridge_install_dir() == Path.home() / ".nanobot" / "bridge" + assert get_legacy_sessions_dir() == Path.home() / ".nanobot" / "sessions" + + +def test_workspace_path_is_explicitly_resolved() -> None: + assert get_workspace_path() == Path.home() / ".nanobot" / "workspace" + assert get_workspace_path("~/custom-workspace") == Path.home() / "custom-workspace" From 0a5daf3c86f2d5c78bdfd63409e8bf45058211b2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 03:03:25 +0000 Subject: [PATCH 096/113] docs: update readme for multiple instances and cli --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5bd11f8..0bb6efe 100644 --- a/README.md +++ b/README.md @@ -903,7 +903,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us | `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | -## Multiple Instances +## ๐Ÿงฉ Multiple Instances Run multiple nanobot instances simultaneously with separate configs and runtime data. Use `--config` as the main entrypoint, and optionally use `--workspace` to override the workspace for a specific run. @@ -992,7 +992,7 @@ nanobot gateway --config ~/.nanobot-telegram/config.json --workspace /tmp/nanobo - `--workspace` overrides the workspace defined in the config file - Cron jobs and runtime media/state are derived from the config directory -## CLI Reference +## ๐Ÿ’ป CLI Reference | Command | Description | |---------|-------------| From bf0ab93b06c395dec1b155ba46dd8e80352a19df Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 03:24:15 +0000 Subject: [PATCH 097/113] Merge branch 'main' into pr-1635 --- README.md | 13 ++++++++--- nanobot/cli/commands.py | 22 ++++++++--------- tests/test_commands.py | 52 ++++++++++++++++++++++++++++++++++++----- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index bc11cc8..13971e2 100644 --- a/README.md +++ b/README.md @@ -724,7 +724,10 @@ nanobot provider login openai-codex nanobot agent -m "Hello!" # Target a specific workspace/config locally -nanobot agent -w ~/.nanobot/botA -c ~/.nanobot/botA/config.json -m "Hello!" +nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!" + +# One-off workspace override on top of that config +nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!" ``` > Docker users: use `docker run -it` for interactive OAuth login. @@ -930,11 +933,15 @@ When using `--config`, nanobot derives its runtime data directory from the confi To open a CLI session against one of these instances locally: ```bash -nanobot agent -w ~/.nanobot/botA -m "Hello from botA" -nanobot agent -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json +nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance" +nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance" + +# Optional one-off workspace override +nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test ``` > `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process. + | Component | Resolved From | Example | |-----------|---------------|---------| | **Config** | `--config` path | `~/.nanobot-A/config.json` | diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index d03ef93..2c8d6d3 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -266,9 +266,17 @@ def _make_provider(config: Config): def _load_runtime_config(config: str | None = None, workspace: str | None = None) -> Config: """Load config and optionally override the active workspace.""" - from nanobot.config.loader import load_config + from nanobot.config.loader import load_config, set_config_path + + config_path = None + if config: + config_path = Path(config).expanduser().resolve() + if not config_path.exists(): + console.print(f"[red]Error: Config file not found: {config_path}[/red]") + raise typer.Exit(1) + set_config_path(config_path) + console.print(f"[dim]Using config: {config_path}[/dim]") - config_path = Path(config) if config else None loaded = load_config(config_path) if workspace: loaded.agents.defaults.workspace = workspace @@ -288,16 +296,6 @@ def gateway( config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), ): """Start the nanobot gateway.""" - # Set config path if provided (must be done before any imports that use get_data_dir) - if config: - from nanobot.config.loader import set_config_path - config_path = Path(config).expanduser().resolve() - if not config_path.exists(): - console.print(f"[red]Error: Config file not found: {config_path}[/red]") - raise typer.Exit(1) - set_config_path(config_path) - console.print(f"[dim]Using config: {config_path}[/dim]") - from nanobot.agent.loop import AgentLoop from nanobot.bus.queue import MessageBus from nanobot.channels.manager import ChannelManager diff --git a/tests/test_commands.py b/tests/test_commands.py index e3709da..19c1998 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -191,13 +191,52 @@ def test_agent_uses_default_config_when_no_workspace_or_config_flags(mock_agent_ mock_agent_runtime["print_response"].assert_called_once_with("mock-response", render_markdown=True) -def test_agent_uses_explicit_config_path(mock_agent_runtime): - config_path = Path("/tmp/agent-config.json") +def test_agent_uses_explicit_config_path(mock_agent_runtime, tmp_path: Path): + config_path = tmp_path / "agent-config.json" + config_path.write_text("{}") result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_path)]) assert result.exit_code == 0 - assert mock_agent_runtime["load_config"].call_args.args == (config_path,) + assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) + + +def test_agent_config_sets_active_path(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + seen: dict[str, Path] = {} + + monkeypatch.setattr( + "nanobot.config.loader.set_config_path", + lambda path: seen.__setitem__("config_path", path), + ) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.config.paths.get_cron_dir", lambda: config_file.parent / "cron") + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr("nanobot.cli.commands._make_provider", lambda _config: object()) + monkeypatch.setattr("nanobot.bus.queue.MessageBus", lambda: object()) + monkeypatch.setattr("nanobot.cron.service.CronService", lambda _store: object()) + + class _FakeAgentLoop: + def __init__(self, *args, **kwargs) -> None: + pass + + async def process_direct(self, *_args, **_kwargs) -> str: + return "ok" + + async def close_mcp(self) -> None: + return None + + monkeypatch.setattr("nanobot.agent.loop.AgentLoop", _FakeAgentLoop) + monkeypatch.setattr("nanobot.cli.commands._print_agent_response", lambda *_args, **_kwargs: None) + + result = runner.invoke(app, ["agent", "-m", "hello", "-c", str(config_file)]) + + assert result.exit_code == 0 + assert seen["config_path"] == config_file.resolve() def test_agent_overrides_workspace_path(mock_agent_runtime): @@ -211,8 +250,9 @@ def test_agent_overrides_workspace_path(mock_agent_runtime): assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path -def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime): - config_path = Path("/tmp/agent-config.json") +def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime, tmp_path: Path): + config_path = tmp_path / "agent-config.json" + config_path.write_text("{}") workspace_path = Path("/tmp/agent-workspace") result = runner.invoke( @@ -221,7 +261,7 @@ def test_agent_workspace_override_wins_over_config_workspace(mock_agent_runtime) ) assert result.exit_code == 0 - assert mock_agent_runtime["load_config"].call_args.args == (config_path,) + assert mock_agent_runtime["load_config"].call_args.args == (config_path.resolve(),) assert mock_agent_runtime["config"].agents.defaults.workspace == str(workspace_path) assert mock_agent_runtime["sync_templates"].call_args.args == (workspace_path,) assert mock_agent_runtime["agent_loop_cls"].call_args.kwargs["workspace"] == workspace_path From 1421ac501c381c253dfca156558b16d6a0f73a64 Mon Sep 17 00:00:00 2001 From: TheAutomatic Date: Sun, 8 Mar 2026 07:04:06 -0700 Subject: [PATCH 098/113] feat(qq): send messages using markdown payload --- nanobot/channels/qq.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 4809fd3..5ac06e3 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -113,16 +113,16 @@ class QQChannel(BaseChannel): if msg_type == "group": await self._client.api.post_group_message( group_openid=msg.chat_id, - msg_type=0, - content=msg.content, + msg_type=2, + markdown={"content": msg.content}, msg_id=msg_id, msg_seq=self._msg_seq, ) else: await self._client.api.post_c2c_message( openid=msg.chat_id, - msg_type=0, - content=msg.content, + msg_type=2, + markdown={"content": msg.content}, msg_id=msg_id, msg_seq=self._msg_seq, ) From ed3b9c16f959d5820298673fe732d899dec9a593 Mon Sep 17 00:00:00 2001 From: Alfredo Arenas Date: Sun, 8 Mar 2026 08:05:18 -0600 Subject: [PATCH 099/113] fix: handle CancelledError in MCP tool calls to prevent process crash MCP SDK's anyio cancel scopes can leak CancelledError on timeout or failure paths. Since CancelledError is a BaseException (not Exception), it escapes both MCPToolWrapper.execute() and ToolRegistry.execute(), crashing the agent loop. Now catches CancelledError and returns a graceful error to the LLM, while still re-raising genuine task cancellations from /stop. Also catches general Exception for other MCP failures (connection drops, invalid responses, etc.). Related: #1055 --- nanobot/agent/tools/mcp.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 2cbffd0..cf0a842 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -34,7 +34,7 @@ class MCPToolWrapper(Tool): def parameters(self) -> dict[str, Any]: return self._parameters - async def execute(self, **kwargs: Any) -> str: +async def execute(self, **kwargs: Any) -> str: from mcp import types try: result = await asyncio.wait_for( @@ -44,13 +44,24 @@ class MCPToolWrapper(Tool): except asyncio.TimeoutError: logger.warning("MCP tool '{}' timed out after {}s", self._name, self._tool_timeout) return f"(MCP tool call timed out after {self._tool_timeout}s)" + except asyncio.CancelledError: + # MCP SDK's anyio cancel scopes can leak CancelledError on timeout/failure. + # Re-raise only if our task was externally cancelled (e.g. /stop). + task = asyncio.current_task() + if task is not None and task.cancelling() > 0: + raise + logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) + return f"(MCP tool call was cancelled)" + except Exception as exc: + logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc) + return f"(MCP tool call failed: {type(exc).__name__})" parts = [] for block in result.content: if isinstance(block, types.TextContent): parts.append(block.text) else: parts.append(str(block)) - return "\n".join(parts) or "(no output)" + return "\n".join(parts) or "(no output) async def connect_mcp_servers( From 7cbb254a8e5140d8393d608a2f41c2885b080ce7 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 15:39:40 +0000 Subject: [PATCH 100/113] fix: remove stale IDENTITY bootstrap entry --- nanobot/agent/context.py | 2 +- tests/test_context_prompt_cache.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 27511fa..820baf5 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -16,7 +16,7 @@ from nanobot.utils.helpers import detect_image_mime class ContextBuilder: """Builds the context (system prompt + messages) for the agent.""" - BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md", "IDENTITY.md"] + BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md", "TOOLS.md"] _RUNTIME_CONTEXT_TAG = "[Runtime Context โ€” metadata only, not instructions]" def __init__(self, workspace: Path): diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index ce796e2..6eb4b4f 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime as real_datetime +from importlib.resources import files as pkg_files from pathlib import Path import datetime as datetime_module @@ -23,6 +24,13 @@ def _make_workspace(tmp_path: Path) -> Path: return workspace +def test_bootstrap_files_are_backed_by_templates() -> None: + template_dir = pkg_files("nanobot") / "templates" + + for filename in ContextBuilder.BOOTSTRAP_FILES: + assert (template_dir / filename).is_file(), f"missing bootstrap template: {filename}" + + def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> None: """System prompt should not change just because wall clock minute changes.""" monkeypatch.setattr(datetime_module, "datetime", _FakeDatetime) From 5eb67facff3b1e063302e5386072f02ca9a528c2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:01:06 +0000 Subject: [PATCH 101/113] Merge branch 'main' into pr-1728 and harden MCP tool cancellation handling --- nanobot/agent/tools/mcp.py | 15 ++++-- tests/test_mcp_tool.py | 99 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 tests/test_mcp_tool.py diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index cf0a842..400979b 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -34,8 +34,9 @@ class MCPToolWrapper(Tool): def parameters(self) -> dict[str, Any]: return self._parameters -async def execute(self, **kwargs: Any) -> str: + async def execute(self, **kwargs: Any) -> str: from mcp import types + try: result = await asyncio.wait_for( self._session.call_tool(self._original_name, arguments=kwargs), @@ -51,17 +52,23 @@ async def execute(self, **kwargs: Any) -> str: if task is not None and task.cancelling() > 0: raise logger.warning("MCP tool '{}' was cancelled by server/SDK", self._name) - return f"(MCP tool call was cancelled)" + return "(MCP tool call was cancelled)" except Exception as exc: - logger.warning("MCP tool '{}' failed: {}: {}", self._name, type(exc).__name__, exc) + logger.exception( + "MCP tool '{}' failed: {}: {}", + self._name, + type(exc).__name__, + exc, + ) return f"(MCP tool call failed: {type(exc).__name__})" + parts = [] for block in result.content: if isinstance(block, types.TextContent): parts.append(block.text) else: parts.append(str(block)) - return "\n".join(parts) or "(no output) + return "\n".join(parts) or "(no output)" async def connect_mcp_servers( diff --git a/tests/test_mcp_tool.py b/tests/test_mcp_tool.py new file mode 100644 index 0000000..bf68425 --- /dev/null +++ b/tests/test_mcp_tool.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import asyncio +import sys +from types import ModuleType, SimpleNamespace + +import pytest + +from nanobot.agent.tools.mcp import MCPToolWrapper + + +class _FakeTextContent: + def __init__(self, text: str) -> None: + self.text = text + + +@pytest.fixture(autouse=True) +def _fake_mcp_module(monkeypatch: pytest.MonkeyPatch) -> None: + mod = ModuleType("mcp") + mod.types = SimpleNamespace(TextContent=_FakeTextContent) + monkeypatch.setitem(sys.modules, "mcp", mod) + + +def _make_wrapper(session: object, *, timeout: float = 0.1) -> MCPToolWrapper: + tool_def = SimpleNamespace( + name="demo", + description="demo tool", + inputSchema={"type": "object", "properties": {}}, + ) + return MCPToolWrapper(session, "test", tool_def, tool_timeout=timeout) + + +@pytest.mark.asyncio +async def test_execute_returns_text_blocks() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + assert arguments == {"value": 1} + return SimpleNamespace(content=[_FakeTextContent("hello"), 42]) + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool)) + + result = await wrapper.execute(value=1) + + assert result == "hello\n42" + + +@pytest.mark.asyncio +async def test_execute_returns_timeout_message() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + await asyncio.sleep(1) + return SimpleNamespace(content=[]) + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=0.01) + + result = await wrapper.execute() + + assert result == "(MCP tool call timed out after 0.01s)" + + +@pytest.mark.asyncio +async def test_execute_handles_server_cancelled_error() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + raise asyncio.CancelledError() + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool)) + + result = await wrapper.execute() + + assert result == "(MCP tool call was cancelled)" + + +@pytest.mark.asyncio +async def test_execute_re_raises_external_cancellation() -> None: + started = asyncio.Event() + + async def call_tool(_name: str, arguments: dict) -> object: + started.set() + await asyncio.sleep(60) + return SimpleNamespace(content=[]) + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool), timeout=10) + task = asyncio.create_task(wrapper.execute()) + await started.wait() + + task.cancel() + + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_execute_handles_generic_exception() -> None: + async def call_tool(_name: str, arguments: dict) -> object: + raise RuntimeError("boom") + + wrapper = _make_wrapper(SimpleNamespace(call_tool=call_tool)) + + result = await wrapper.execute() + + assert result == "(MCP tool call failed: RuntimeError)" From 4715321319f7282ffe9df99be59898a9782a2440 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:39:37 +0000 Subject: [PATCH 102/113] Merge branch 'main' into pr-1579 and tighten platform guidance --- nanobot/agent/context.py | 25 ++++++------------------- nanobot/skills/memory/SKILL.md | 13 +++++++------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 3dced80..2c648eb 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -62,27 +62,14 @@ Skills with available="false" need dependencies installed first - you can try in platform_policy = "" if system == "Windows": platform_policy = """## Platform Policy (Windows) -- You are running on Windows. Shell commands executed via the `exec` tool run under the default Windows shell (PowerShell or cmd.exe) unless you explicitly invoke another shell. -- Prefer UTF-8 for file I/O and command output. If terminal output is garbled/mojibake, retry with: - - PowerShell: `[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ` - - cmd.exe: `chcp 65001 >NUL & ` -- Do NOT assume GNU tools like `grep`, `sed`, `awk` exist. Prefer Windows built-ins: - - Search text: `findstr /i "keyword" path\\to\\file` - - List files: `dir` - - Show file: `type path\\to\\file` -- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reliability. -""" - elif system == "Darwin": - platform_policy = """## Platform Policy (macOS) -- You are running on macOS. Prefer POSIX tools and UTF-8. -- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. -- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. +- You are running on Windows. Do not assume GNU tools like `grep`, `sed`, or `awk` exist. +- Prefer Windows-native commands or file tools when they are more reliable. +- If terminal output is garbled, retry with UTF-8 output enabled. """ else: - platform_policy = """## Platform Policy (Linux) -- You are running on Linux. Prefer POSIX tools and UTF-8. -- Use forward-slash paths. Prefer `ls`, `cat`, `grep`, `find` for filesystem and text operations. -- When in doubt, prefer the file tools (`read_file`, `list_dir`) over shell for portability and reproducibility. + platform_policy = """## Platform Policy (POSIX) +- You are running on a POSIX system. Prefer UTF-8 and standard shell tools. +- Use file tools when they are simpler or more reliable than shell commands. """ return f"""# nanobot ๐Ÿˆ diff --git a/nanobot/skills/memory/SKILL.md b/nanobot/skills/memory/SKILL.md index 865f11f..3f0a8fc 100644 --- a/nanobot/skills/memory/SKILL.md +++ b/nanobot/skills/memory/SKILL.md @@ -13,16 +13,17 @@ always: true ## Search Past Events -**Recommended approach (cross-platform):** -- Use `read_file` to read `memory/HISTORY.md`, then search in-memory -- This is the most reliable and portable method on all platforms +Choose the search method based on file size: -**Alternative (if you need command-line search):** +- Small `memory/HISTORY.md`: use `read_file`, then search in-memory +- Large or long-lived `memory/HISTORY.md`: use the `exec` tool for targeted search + +Examples: - **Linux/macOS:** `grep -i "keyword" memory/HISTORY.md` - **Windows:** `findstr /i "keyword" memory\HISTORY.md` -- **Python (cross-platform):** `python -c "import re; content=open('memory/HISTORY.md', encoding='utf-8').read(); print('\n'.join([l for l in content.split('\n') if 'keyword' in l.lower()][-20:]))"` +- **Cross-platform Python:** `python -c "from pathlib import Path; text = Path('memory/HISTORY.md').read_text(encoding='utf-8'); print('\n'.join([l for l in text.splitlines() if 'keyword' in l.lower()][-20:]))"` -Use the `exec` tool to run these commands. For complex searches, prefer `read_file` + in-memory filtering. +Prefer targeted command-line search for large history files. ## When to Update MEMORY.md From a0bb4320f48bd4f25e9daf98de7ad2eb9276a42e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:44:47 +0000 Subject: [PATCH 103/113] chore: bump version to 0.1.4.post4 --- nanobot/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/nanobot/__init__.py b/nanobot/__init__.py index 4dba5f4..d331109 100644 --- a/nanobot/__init__.py +++ b/nanobot/__init__.py @@ -2,5 +2,5 @@ nanobot - A lightweight AI agent framework """ -__version__ = "0.1.4.post3" +__version__ = "0.1.4.post4" __logo__ = "๐Ÿˆ" diff --git a/pyproject.toml b/pyproject.toml index 41d0fbb..62cf616 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "nanobot-ai" -version = "0.1.4.post3" +version = "0.1.4.post4" description = "A lightweight personal AI assistant framework" requires-python = ">=3.11" license = {text = "MIT"} From 998021f571a140574af0c29a3c36f51b7ff71e79 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 16:57:28 +0000 Subject: [PATCH 104/113] docs: refresh install/update guidance and bump v0.1.4.post4 --- README.md | 31 +++++++++++++++++++++++++++---- SECURITY.md | 4 ++-- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 13971e2..d3401ea 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,29 @@ uv tool install nanobot-ai pip install nanobot-ai ``` +### Update to latest version + +**PyPI / pip** + +```bash +pip install -U nanobot-ai +nanobot --version +``` + +**uv** + +```bash +uv tool upgrade nanobot-ai +nanobot --version +``` + +**Using WhatsApp?** Rebuild the local bridge after upgrading: + +```bash +rm -rf ~/.nanobot/bridge +nanobot channels login +``` + ## ๐Ÿš€ Quick Start > [!TIP] @@ -374,7 +397,7 @@ pip install nanobot-ai[matrix] | Option | Description | |--------|-------------| -| `allowFrom` | User IDs allowed to interact. Empty = all senders. | +| `allowFrom` | User IDs allowed to interact. Empty denies all; use `["*"]` to allow everyone. | | `groupPolicy` | `open` (default), `mention`, or `allowlist`. | | `groupAllowFrom` | Room allowlist (used when policy is `allowlist`). | | `allowRoomMentions` | Accept `@room` mentions in mention mode. | @@ -428,7 +451,7 @@ nanobot gateway ``` > WhatsApp bridge updates are not applied automatically for existing installations. -> If you upgrade nanobot and need the latest WhatsApp bridge, run: +> After upgrading nanobot, rebuild the local bridge with: > `rm -rf ~/.nanobot/bridge && nanobot channels login`
@@ -900,13 +923,13 @@ MCP tools are automatically discovered and registered on startup. The LLM can us > [!TIP] > For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent. -> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`. +> In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all senders. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default. To allow all senders, set `"allowFrom": ["*"]`. | Option | Default | Description | |--------|---------|-------------| | `tools.restrictToWorkspace` | `false` | When `true`, restricts **all** agent tools (shell, file read/write/edit, list) to the workspace directory. Prevents path traversal and out-of-scope access. | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | -| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. | +| `channels.*.allowFrom` | `[]` (deny all) | Whitelist of user IDs. Empty denies all; use `["*"]` to allow everyone. | ## ๐Ÿงฉ Multiple Instances diff --git a/SECURITY.md b/SECURITY.md index af4da71..d98adb6 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json ``` **Security Notes:** -- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** โ€” set `["*"]` to explicitly allow everyone. +- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default โ€” set `["*"]` to explicitly allow everyone. - Get your Telegram user ID from `@userinfobot` - Use full phone numbers with country code for WhatsApp - Review access logs regularly for unauthorized access attempts @@ -212,7 +212,7 @@ If you suspect a security breach: - Input length limits on HTTP requests โœ… **Authentication** -- Allow-list based access control โ€” in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all) +- Allow-list based access control โ€” in `v0.1.4.post3` and earlier empty `allowFrom` allowed all; since `v0.1.4.post4` it denies all (`["*"]` explicitly allows all) - Failed authentication attempt logging โœ… **Resource Protection** From 4147d0ff9d12f9faaa3aefe5be449b18461588d1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 17:00:09 +0000 Subject: [PATCH 105/113] docs: update v0.1.4.post4 release news --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d3401ea..2450b8c 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ ## ๐Ÿ“ข News +- **2026-03-08** ๐Ÿš€ Released **v0.1.4.post4** โ€” a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. - **2026-03-07** ๐Ÿš€ Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. - **2026-03-06** ๐Ÿช„ Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. - **2026-03-05** โšก๏ธ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. From f19cefb1b9b61dcf902afb5666aea80b1c362e46 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Sun, 8 Mar 2026 17:00:46 +0000 Subject: [PATCH 106/113] docs: update v0.1.4.post4 release news --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2450b8c..f169bd7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ ## ๐Ÿ“ข News -- **2026-03-08** ๐Ÿš€ Released **v0.1.4.post4** โ€” a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP/tooling, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. +- **2026-03-08** ๐Ÿš€ Released **v0.1.4.post4** โ€” a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details. - **2026-03-07** ๐Ÿš€ Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish. - **2026-03-06** ๐Ÿช„ Lighter providers, smarter media handling, and sturdier memory and CLI compatibility. - **2026-03-05** โšก๏ธ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes. From 4044b85d4bfa9104b633f3cb408894f0459a0164 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 9 Mar 2026 01:32:10 +0800 Subject: [PATCH 107/113] fix: ensure feishu audio file has .opus extension for Groq Whisper compatibility --- nanobot/channels/feishu.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a637025..0409c32 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -753,8 +753,9 @@ class FeishuChannel(BaseChannel): None, self._download_file_sync, message_id, file_key, msg_type ) if not filename: - ext = {"audio": ".opus", "media": ".mp4"}.get(msg_type, "") - filename = f"{file_key[:16]}{ext}" + filename = file_key[:16] + if msg_type == "audio" and not filename.endswith(".opus"): + filename = f"{filename}.opus" if data and filename: file_path = media_dir / filename From 620d7896c710748053257695d25c3391aa637dc5 Mon Sep 17 00:00:00 2001 From: ailuntz Date: Tue, 10 Mar 2026 00:14:34 +0800 Subject: [PATCH 108/113] fix(slack): define thread usage when sending messages --- nanobot/channels/slack.py | 2 +- tests/test_slack_channel.py | 88 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 tests/test_slack_channel.py diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index a4e7324..e36c4c9 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -82,6 +82,7 @@ class SlackChannel(BaseChannel): thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") # Only reply in thread for channel/group messages; DMs don't use threads + use_thread = bool(thread_ts and channel_type != "im") thread_ts_param = thread_ts if use_thread else None # Slack rejects empty text payloads. Keep media-only messages media-only, @@ -278,4 +279,3 @@ class SlackChannel(BaseChannel): if parts: rows.append(" ยท ".join(parts)) return "\n".join(rows) - diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py new file mode 100644 index 0000000..18b96ef --- /dev/null +++ b/tests/test_slack_channel.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import pytest + +from nanobot.bus.events import OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.channels.slack import SlackChannel +from nanobot.config.schema import SlackConfig + + +class _FakeAsyncWebClient: + def __init__(self) -> None: + self.chat_post_calls: list[dict[str, object | None]] = [] + self.file_upload_calls: list[dict[str, object | None]] = [] + + async def chat_postMessage( + self, + *, + channel: str, + text: str, + thread_ts: str | None = None, + ) -> None: + self.chat_post_calls.append( + { + "channel": channel, + "text": text, + "thread_ts": thread_ts, + } + ) + + async def files_upload_v2( + self, + *, + channel: str, + file: str, + thread_ts: str | None = None, + ) -> None: + self.file_upload_calls.append( + { + "channel": channel, + "file": file, + "thread_ts": thread_ts, + } + ) + + +@pytest.mark.asyncio +async def test_send_uses_thread_for_channel_messages() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="C123", + content="hello", + media=["/tmp/demo.txt"], + metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "channel"}}, + ) + ) + + assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100" + assert len(fake_web.file_upload_calls) == 1 + assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100" + + +@pytest.mark.asyncio +async def test_send_omits_thread_for_dm_messages() -> None: + channel = SlackChannel(SlackConfig(enabled=True), MessageBus()) + fake_web = _FakeAsyncWebClient() + channel._web_client = fake_web + + await channel.send( + OutboundMessage( + channel="slack", + chat_id="D123", + content="hello", + media=["/tmp/demo.txt"], + metadata={"slack": {"thread_ts": "1700000000.000100", "channel_type": "im"}}, + ) + ) + + assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["thread_ts"] is None + assert len(fake_web.file_upload_calls) == 1 + assert fake_web.file_upload_calls[0]["thread_ts"] is None From 9c88e40a616190aca65ce3d3149f4529865ca5d8 Mon Sep 17 00:00:00 2001 From: ailuntz Date: Tue, 10 Mar 2026 00:32:42 +0800 Subject: [PATCH 109/113] fix(cli): respect gateway port from config when --port omitted --- nanobot/cli/commands.py | 5 +++-- tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2c8d6d3..a5906d2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None @app.command() def gateway( - port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -310,8 +310,9 @@ def gateway( logging.basicConfig(level=logging.DEBUG) config = _load_runtime_config(config, workspace) + selected_port = port if port is not None else config.gateway.port - console.print(f"{__logo__} Starting nanobot gateway on port {port}...") + console.print(f"{__logo__} Starting nanobot gateway on port {selected_port}...") sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) diff --git a/tests/test_commands.py b/tests/test_commands.py index 19c1998..9479dad 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -328,6 +328,50 @@ def test_gateway_workspace_option_overrides_config(monkeypatch, tmp_path: Path) assert config.workspace_path == override +def test_gateway_uses_port_from_config_when_cli_port_is_omitted(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.gateway.port = 18791 + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert "Starting nanobot gateway on port 18791" in result.stdout + + +def test_gateway_cli_port_overrides_config_port(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.gateway.port = 18791 + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18801"]) + + assert isinstance(result.exception, _StopGateway) + assert "Starting nanobot gateway on port 18801" in result.stdout + + def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Path) -> None: config_file = tmp_path / "instance" / "config.json" config_file.parent.mkdir(parents=True) From 28330940d0b2cefbfe740957ee8f51ed9349c24e Mon Sep 17 00:00:00 2001 From: Re-bin Date: Mon, 9 Mar 2026 17:18:10 +0000 Subject: [PATCH 110/113] fix(slack): skip thread_ts for direct messages --- nanobot/channels/slack.py | 5 ++--- tests/test_slack_channel.py | 2 ++ 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index e36c4c9..0384d8d 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -81,9 +81,8 @@ class SlackChannel(BaseChannel): slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {} thread_ts = slack_meta.get("thread_ts") channel_type = slack_meta.get("channel_type") - # Only reply in thread for channel/group messages; DMs don't use threads - use_thread = bool(thread_ts and channel_type != "im") - thread_ts_param = thread_ts if use_thread else None + # Slack DMs don't use threads; channel/group replies may keep thread_ts. + thread_ts_param = thread_ts if thread_ts and channel_type != "im" else None # Slack rejects empty text payloads. Keep media-only messages media-only, # but send a single blank message when the bot has no text or files to send. diff --git a/tests/test_slack_channel.py b/tests/test_slack_channel.py index 18b96ef..891f86a 100644 --- a/tests/test_slack_channel.py +++ b/tests/test_slack_channel.py @@ -61,6 +61,7 @@ async def test_send_uses_thread_for_channel_messages() -> None: ) assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["text"] == "hello\n" assert fake_web.chat_post_calls[0]["thread_ts"] == "1700000000.000100" assert len(fake_web.file_upload_calls) == 1 assert fake_web.file_upload_calls[0]["thread_ts"] == "1700000000.000100" @@ -83,6 +84,7 @@ async def test_send_omits_thread_for_dm_messages() -> None: ) assert len(fake_web.chat_post_calls) == 1 + assert fake_web.chat_post_calls[0]["text"] == "hello\n" assert fake_web.chat_post_calls[0]["thread_ts"] is None assert len(fake_web.file_upload_calls) == 1 assert fake_web.file_upload_calls[0]["thread_ts"] is None From 1284c7217ea2c59a5a9e2786c5f550e9fb5ace1b Mon Sep 17 00:00:00 2001 From: Protocol Zero <257158451+Protocol-zero-0@users.noreply.github.com> Date: Mon, 9 Mar 2026 20:12:11 +0000 Subject: [PATCH 111/113] fix(cli): let gateway use config port by default Respect config.gateway.port when --port is omitted, while keeping CLI flags as the highest-precedence override. --- nanobot/cli/commands.py | 3 ++- tests/test_commands.py | 44 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 2c8d6d3..37f08b2 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -290,7 +290,7 @@ def _load_runtime_config(config: str | None = None, workspace: str | None = None @app.command() def gateway( - port: int = typer.Option(18790, "--port", "-p", help="Gateway port"), + port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"), workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"), verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"), config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"), @@ -310,6 +310,7 @@ def gateway( logging.basicConfig(level=logging.DEBUG) config = _load_runtime_config(config, workspace) + port = port if port is not None else config.gateway.port console.print(f"{__logo__} Starting nanobot gateway on port {port}...") sync_workspace_templates(config.workspace_path) diff --git a/tests/test_commands.py b/tests/test_commands.py index 19c1998..5d38942 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -356,3 +356,47 @@ def test_gateway_uses_config_directory_for_cron_store(monkeypatch, tmp_path: Pat assert isinstance(result.exception, _StopGateway) assert seen["cron_store"] == config_file.parent / "cron" / "jobs.json" + + +def test_gateway_uses_configured_port_when_cli_flag_is_missing(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.gateway.port = 18791 + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file)]) + + assert isinstance(result.exception, _StopGateway) + assert "port 18791" in result.stdout + + +def test_gateway_cli_port_overrides_configured_port(monkeypatch, tmp_path: Path) -> None: + config_file = tmp_path / "instance" / "config.json" + config_file.parent.mkdir(parents=True) + config_file.write_text("{}") + + config = Config() + config.gateway.port = 18791 + + monkeypatch.setattr("nanobot.config.loader.set_config_path", lambda _path: None) + monkeypatch.setattr("nanobot.config.loader.load_config", lambda _path=None: config) + monkeypatch.setattr("nanobot.cli.commands.sync_workspace_templates", lambda _path: None) + monkeypatch.setattr( + "nanobot.cli.commands._make_provider", + lambda _config: (_ for _ in ()).throw(_StopGateway("stop")), + ) + + result = runner.invoke(app, ["gateway", "--config", str(config_file), "--port", "18792"]) + + assert isinstance(result.exception, _StopGateway) + assert "port 18792" in result.stdout From 4f9857f85f1f8aeddceb019bc0062d3ba7cab032 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 04:34:15 +0000 Subject: [PATCH 112/113] feat(telegram): add configurable group mention policy --- nanobot/channels/telegram.py | 86 ++++++++++++++---- nanobot/config/schema.py | 2 +- tests/test_telegram_channel.py | 156 ++++++++++++++++++++++++++++++++- 3 files changed, 226 insertions(+), 18 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 0821b7d..5b294cc 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -179,6 +179,8 @@ class TelegramChannel(BaseChannel): self._media_group_buffers: dict[str, dict] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} self._message_threads: dict[tuple[str, int], int] = {} + self._bot_user_id: int | None = None + self._bot_username: str | None = None def is_allowed(self, sender_id: str) -> bool: """Preserve Telegram's legacy id|username allowlist matching.""" @@ -242,6 +244,8 @@ class TelegramChannel(BaseChannel): # Get bot info and register command menu bot_info = await self._app.bot.get_me() + self._bot_user_id = getattr(bot_info, "id", None) + self._bot_username = getattr(bot_info, "username", None) logger.info("Telegram bot @{} connected", bot_info.username) try: @@ -462,6 +466,70 @@ class TelegramChannel(BaseChannel): "is_forum": bool(getattr(message.chat, "is_forum", False)), } + async def _ensure_bot_identity(self) -> tuple[int | None, str | None]: + """Load bot identity once and reuse it for mention/reply checks.""" + if self._bot_user_id is not None or self._bot_username is not None: + return self._bot_user_id, self._bot_username + if not self._app: + return None, None + bot_info = await self._app.bot.get_me() + self._bot_user_id = getattr(bot_info, "id", None) + self._bot_username = getattr(bot_info, "username", None) + return self._bot_user_id, self._bot_username + + @staticmethod + def _has_mention_entity( + text: str, + entities, + bot_username: str, + bot_id: int | None, + ) -> bool: + """Check Telegram mention entities against the bot username.""" + handle = f"@{bot_username}".lower() + for entity in entities or []: + entity_type = getattr(entity, "type", None) + if entity_type == "text_mention": + user = getattr(entity, "user", None) + if user is not None and bot_id is not None and getattr(user, "id", None) == bot_id: + return True + continue + if entity_type != "mention": + continue + offset = getattr(entity, "offset", None) + length = getattr(entity, "length", None) + if offset is None or length is None: + continue + if text[offset : offset + length].lower() == handle: + return True + return handle in text.lower() + + async def _is_group_message_for_bot(self, message) -> bool: + """Allow group messages when policy is open, @mentioned, or replying to the bot.""" + if message.chat.type == "private" or self.config.group_policy == "open": + return True + + bot_id, bot_username = await self._ensure_bot_identity() + if bot_username: + text = message.text or "" + caption = message.caption or "" + if self._has_mention_entity( + text, + getattr(message, "entities", None), + bot_username, + bot_id, + ): + return True + if self._has_mention_entity( + caption, + getattr(message, "caption_entities", None), + bot_username, + bot_id, + ): + return True + + reply_user = getattr(getattr(message, "reply_to_message", None), "from_user", None) + return bool(bot_id and reply_user and reply_user.id == bot_id) + def _remember_thread_context(self, message) -> None: """Cache topic thread id by chat/message id for follow-up replies.""" message_thread_id = getattr(message, "message_thread_id", None) @@ -501,22 +569,8 @@ class TelegramChannel(BaseChannel): # Store chat_id for replies self._chat_ids[sender_id] = chat_id - # Enforce group_policy: in group chats with "mention" policy, - # only respond when the bot is @mentioned or the message is a reply to the bot. - is_group = message.chat.type != "private" - if is_group and getattr(self.config, "group_policy", "open") == "mention": - bot_username = (await self._app.bot.get_me()).username if self._app else None - mentioned = False - # Check if bot is @mentioned in text - if bot_username and message.text: - mentioned = f"@{bot_username}" in message.text - # Check if the message is a reply to the bot - if not mentioned and message.reply_to_message and message.reply_to_message.from_user: - bot_id = (await self._app.bot.get_me()).id if self._app else None - if bot_id and message.reply_to_message.from_user.id == bot_id: - mentioned = True - if not mentioned: - return + if not await self._is_group_message_for_bot(message): + return # Build content from text and/or media content_parts = [] diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 3c5e315..8cfcad6 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -33,7 +33,7 @@ class TelegramConfig(Base): None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080" ) reply_to_message: bool = False # If true, bot replies quote the original message - group_policy: Literal["open", "mention"] = "open" # "open" responds to all, "mention" only when @mentioned or replied to + group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned or replied to, "open" responds to all class FeishuConfig(Base): diff --git a/tests/test_telegram_channel.py b/tests/test_telegram_channel.py index 88c3f54..678512d 100644 --- a/tests/test_telegram_channel.py +++ b/tests/test_telegram_channel.py @@ -27,9 +27,11 @@ class _FakeUpdater: class _FakeBot: def __init__(self) -> None: self.sent_messages: list[dict] = [] + self.get_me_calls = 0 async def get_me(self): - return SimpleNamespace(username="nanobot_test") + self.get_me_calls += 1 + return SimpleNamespace(id=999, username="nanobot_test") async def set_my_commands(self, commands) -> None: self.commands = commands @@ -37,6 +39,9 @@ class _FakeBot: async def send_message(self, **kwargs) -> None: self.sent_messages.append(kwargs) + async def send_chat_action(self, **kwargs) -> None: + pass + class _FakeApp: def __init__(self, on_start_polling) -> None: @@ -87,6 +92,35 @@ class _FakeBuilder: return self.app +def _make_telegram_update( + *, + chat_type: str = "group", + text: str | None = None, + caption: str | None = None, + entities=None, + caption_entities=None, + reply_to_message=None, +): + user = SimpleNamespace(id=12345, username="alice", first_name="Alice") + message = SimpleNamespace( + chat=SimpleNamespace(type=chat_type, is_forum=False), + chat_id=-100123, + text=text, + caption=caption, + entities=entities or [], + caption_entities=caption_entities or [], + reply_to_message=reply_to_message, + photo=None, + voice=None, + audio=None, + document=None, + media_group_id=None, + message_thread_id=None, + message_id=1, + ) + return SimpleNamespace(message=message, effective_user=user) + + @pytest.mark.asyncio async def test_start_uses_request_proxy_without_builder_proxy(monkeypatch) -> None: config = TelegramConfig( @@ -131,6 +165,10 @@ def test_get_extension_falls_back_to_original_filename() -> None: assert channel._get_extension("file", None, "archive.tar.gz") == ".tar.gz" +def test_telegram_group_policy_defaults_to_mention() -> None: + assert TelegramConfig().group_policy == "mention" + + def test_is_allowed_accepts_legacy_telegram_id_username_formats() -> None: channel = TelegramChannel(TelegramConfig(allow_from=["12345", "alice", "67890|bob"]), MessageBus()) @@ -182,3 +220,119 @@ async def test_send_reply_infers_topic_from_message_id_cache() -> None: assert channel._app.bot.sent_messages[0]["message_thread_id"] == 42 assert channel._app.bot.sent_messages[0]["reply_parameters"].message_id == 10 + + +@pytest.mark.asyncio +async def test_group_policy_mention_ignores_unmentioned_group_message() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + await channel._on_message(_make_telegram_update(text="hello everyone"), None) + + assert handled == [] + assert channel._app.bot.get_me_calls == 1 + + +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_text_mention_and_caches_bot_identity() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + mention = SimpleNamespace(type="mention", offset=0, length=13) + await channel._on_message(_make_telegram_update(text="@nanobot_test hi", entities=[mention]), None) + await channel._on_message(_make_telegram_update(text="@nanobot_test again", entities=[mention]), None) + + assert len(handled) == 2 + assert channel._app.bot.get_me_calls == 1 + + +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_caption_mention() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + mention = SimpleNamespace(type="mention", offset=0, length=13) + await channel._on_message( + _make_telegram_update(caption="@nanobot_test photo", caption_entities=[mention]), + None, + ) + + assert len(handled) == 1 + assert handled[0]["content"] == "@nanobot_test photo" + + +@pytest.mark.asyncio +async def test_group_policy_mention_accepts_reply_to_bot() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="mention"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + reply = SimpleNamespace(from_user=SimpleNamespace(id=999)) + await channel._on_message(_make_telegram_update(text="reply", reply_to_message=reply), None) + + assert len(handled) == 1 + + +@pytest.mark.asyncio +async def test_group_policy_open_accepts_plain_group_message() -> None: + channel = TelegramChannel( + TelegramConfig(enabled=True, token="123:abc", allow_from=["*"], group_policy="open"), + MessageBus(), + ) + channel._app = _FakeApp(lambda: None) + + handled = [] + + async def capture_handle(**kwargs) -> None: + handled.append(kwargs) + + channel._handle_message = capture_handle + channel._start_typing = lambda _chat_id: None + + await channel._on_message(_make_telegram_update(text="hello group"), None) + + assert len(handled) == 1 + assert channel._app.bot.get_me_calls == 0 From b7ecc94c9b85aadc79e0d6598ea42ad7dbaa15f1 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Tue, 10 Mar 2026 09:16:23 +0000 Subject: [PATCH 113/113] fix(skill-creator): restore validation and align packaging docs --- nanobot/skills/skill-creator/SKILL.md | 10 +- .../skill-creator/scripts/package_skill.py | 77 ++++--- .../skill-creator/scripts/quick_validate.py | 213 ++++++++++++++++++ tests/test_skill_creator_scripts.py | 127 +++++++++++ 4 files changed, 392 insertions(+), 35 deletions(-) create mode 100644 nanobot/skills/skill-creator/scripts/quick_validate.py create mode 100644 tests/test_skill_creator_scripts.py diff --git a/nanobot/skills/skill-creator/SKILL.md b/nanobot/skills/skill-creator/SKILL.md index f4d6e0b..ea53abe 100644 --- a/nanobot/skills/skill-creator/SKILL.md +++ b/nanobot/skills/skill-creator/SKILL.md @@ -268,6 +268,8 @@ Skip this step only if the skill being developed already exists, and iteration o When creating a new skill from scratch, always run the `init_skill.py` script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable. +For `nanobot`, custom skills should live under the active workspace `skills/` directory so they can be discovered automatically at runtime (for example, `/skills/my-skill/SKILL.md`). + Usage: ```bash @@ -277,9 +279,9 @@ scripts/init_skill.py --path [--resources script Examples: ```bash -scripts/init_skill.py my-skill --path skills/public -scripts/init_skill.py my-skill --path skills/public --resources scripts,references -scripts/init_skill.py my-skill --path skills/public --resources scripts --examples +scripts/init_skill.py my-skill --path ./workspace/skills +scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts,references +scripts/init_skill.py my-skill --path ./workspace/skills --resources scripts --examples ``` The script: @@ -326,7 +328,7 @@ Write the YAML frontmatter with `name` and `description`: - Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to the agent. - Example description for a `docx` skill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when the agent needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" -Do not include any other fields in YAML frontmatter. +Keep frontmatter minimal. In `nanobot`, `metadata` and `always` are also supported when needed, but avoid adding extra fields unless they are actually required. ##### Body diff --git a/nanobot/skills/skill-creator/scripts/package_skill.py b/nanobot/skills/skill-creator/scripts/package_skill.py index aa4de89..48fcbbe 100755 --- a/nanobot/skills/skill-creator/scripts/package_skill.py +++ b/nanobot/skills/skill-creator/scripts/package_skill.py @@ -3,11 +3,11 @@ Skill Packager - Creates a distributable .skill file of a skill folder Usage: - python utils/package_skill.py [output-directory] + python package_skill.py [output-directory] Example: - python utils/package_skill.py skills/public/my-skill - python utils/package_skill.py skills/public/my-skill ./dist + python package_skill.py skills/public/my-skill + python package_skill.py skills/public/my-skill ./dist """ import sys @@ -25,6 +25,14 @@ def _is_within(path: Path, root: Path) -> bool: return False +def _cleanup_partial_archive(skill_filename: Path) -> None: + try: + if skill_filename.exists(): + skill_filename.unlink() + except OSError: + pass + + def package_skill(skill_path, output_dir=None): """ Package a skill folder into a .skill file. @@ -74,49 +82,56 @@ def package_skill(skill_path, output_dir=None): EXCLUDED_DIRS = {".git", ".svn", ".hg", "__pycache__", "node_modules"} + files_to_package = [] + resolved_archive = skill_filename.resolve() + + for file_path in skill_path.rglob("*"): + # Fail closed on symlinks so the packaged contents are explicit and predictable. + if file_path.is_symlink(): + print(f"[ERROR] Symlink not allowed in packaged skill: {file_path}") + _cleanup_partial_archive(skill_filename) + return None + + rel_parts = file_path.relative_to(skill_path).parts + if any(part in EXCLUDED_DIRS for part in rel_parts): + continue + + if file_path.is_file(): + resolved_file = file_path.resolve() + if not _is_within(resolved_file, skill_path): + print(f"[ERROR] File escapes skill root: {file_path}") + _cleanup_partial_archive(skill_filename) + return None + # If output lives under skill_path, avoid writing archive into itself. + if resolved_file == resolved_archive: + print(f"[WARN] Skipping output archive: {file_path}") + continue + files_to_package.append(file_path) + # Create the .skill file (zip format) try: with zipfile.ZipFile(skill_filename, "w", zipfile.ZIP_DEFLATED) as zipf: - # Walk through the skill directory - for file_path in skill_path.rglob("*"): - # Security: never follow or package symlinks. - if file_path.is_symlink(): - print(f"[WARN] Skipping symlink: {file_path}") - continue - - rel_parts = file_path.relative_to(skill_path).parts - if any(part in EXCLUDED_DIRS for part in rel_parts): - continue - - if file_path.is_file(): - resolved_file = file_path.resolve() - if not _is_within(resolved_file, skill_path): - print(f"[ERROR] File escapes skill root: {file_path}") - return None - # If output lives under skill_path, avoid writing archive into itself. - if resolved_file == skill_filename.resolve(): - print(f"[WARN] Skipping output archive: {file_path}") - continue - - # Calculate the relative path within the zip. - arcname = Path(skill_name) / file_path.relative_to(skill_path) - zipf.write(file_path, arcname) - print(f" Added: {arcname}") + for file_path in files_to_package: + # Calculate the relative path within the zip. + arcname = Path(skill_name) / file_path.relative_to(skill_path) + zipf.write(file_path, arcname) + print(f" Added: {arcname}") print(f"\n[OK] Successfully packaged skill to: {skill_filename}") return skill_filename except Exception as e: + _cleanup_partial_archive(skill_filename) print(f"[ERROR] Error creating .skill file: {e}") return None def main(): if len(sys.argv) < 2: - print("Usage: python utils/package_skill.py [output-directory]") + print("Usage: python package_skill.py [output-directory]") print("\nExample:") - print(" python utils/package_skill.py skills/public/my-skill") - print(" python utils/package_skill.py skills/public/my-skill ./dist") + print(" python package_skill.py skills/public/my-skill") + print(" python package_skill.py skills/public/my-skill ./dist") sys.exit(1) skill_path = sys.argv[1] diff --git a/nanobot/skills/skill-creator/scripts/quick_validate.py b/nanobot/skills/skill-creator/scripts/quick_validate.py new file mode 100644 index 0000000..03d246d --- /dev/null +++ b/nanobot/skills/skill-creator/scripts/quick_validate.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +""" +Minimal validator for nanobot skill folders. +""" + +import re +import sys +from pathlib import Path +from typing import Optional + +try: + import yaml +except ModuleNotFoundError: + yaml = None + +MAX_SKILL_NAME_LENGTH = 64 +ALLOWED_FRONTMATTER_KEYS = { + "name", + "description", + "metadata", + "always", + "license", + "allowed-tools", +} +ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"} +PLACEHOLDER_MARKERS = ("[todo", "todo:") + + +def _extract_frontmatter(content: str) -> Optional[str]: + lines = content.splitlines() + if not lines or lines[0].strip() != "---": + return None + for i in range(1, len(lines)): + if lines[i].strip() == "---": + return "\n".join(lines[1:i]) + return None + + +def _parse_simple_frontmatter(frontmatter_text: str) -> Optional[dict[str, str]]: + """Fallback parser for simple frontmatter when PyYAML is unavailable.""" + parsed: dict[str, str] = {} + current_key: Optional[str] = None + multiline_key: Optional[str] = None + + for raw_line in frontmatter_text.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + + is_indented = raw_line[:1].isspace() + if is_indented: + if current_key is None: + return None + current_value = parsed[current_key] + parsed[current_key] = f"{current_value}\n{stripped}" if current_value else stripped + continue + + if ":" not in stripped: + return None + + key, value = stripped.split(":", 1) + key = key.strip() + value = value.strip() + if not key: + return None + + if value in {"|", ">"}: + parsed[key] = "" + current_key = key + multiline_key = key + continue + + if (value.startswith('"') and value.endswith('"')) or ( + value.startswith("'") and value.endswith("'") + ): + value = value[1:-1] + parsed[key] = value + current_key = key + multiline_key = None + + if multiline_key is not None and multiline_key not in parsed: + return None + return parsed + + +def _load_frontmatter(frontmatter_text: str) -> tuple[Optional[dict], Optional[str]]: + if yaml is not None: + try: + frontmatter = yaml.safe_load(frontmatter_text) + except yaml.YAMLError as exc: + return None, f"Invalid YAML in frontmatter: {exc}" + if not isinstance(frontmatter, dict): + return None, "Frontmatter must be a YAML dictionary" + return frontmatter, None + + frontmatter = _parse_simple_frontmatter(frontmatter_text) + if frontmatter is None: + return None, "Invalid YAML in frontmatter: unsupported syntax without PyYAML installed" + return frontmatter, None + + +def _validate_skill_name(name: str, folder_name: str) -> Optional[str]: + if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): + return ( + f"Name '{name}' should be hyphen-case " + "(lowercase letters, digits, and single hyphens only)" + ) + if len(name) > MAX_SKILL_NAME_LENGTH: + return ( + f"Name is too long ({len(name)} characters). " + f"Maximum is {MAX_SKILL_NAME_LENGTH} characters." + ) + if name != folder_name: + return f"Skill name '{name}' must match directory name '{folder_name}'" + return None + + +def _validate_description(description: str) -> Optional[str]: + trimmed = description.strip() + if not trimmed: + return "Description cannot be empty" + lowered = trimmed.lower() + if any(marker in lowered for marker in PLACEHOLDER_MARKERS): + return "Description still contains TODO placeholder text" + if "<" in trimmed or ">" in trimmed: + return "Description cannot contain angle brackets (< or >)" + if len(trimmed) > 1024: + return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters." + return None + + +def validate_skill(skill_path): + """Validate a skill folder structure and required frontmatter.""" + skill_path = Path(skill_path).resolve() + + if not skill_path.exists(): + return False, f"Skill folder not found: {skill_path}" + if not skill_path.is_dir(): + return False, f"Path is not a directory: {skill_path}" + + skill_md = skill_path / "SKILL.md" + if not skill_md.exists(): + return False, "SKILL.md not found" + + try: + content = skill_md.read_text(encoding="utf-8") + except OSError as exc: + return False, f"Could not read SKILL.md: {exc}" + + frontmatter_text = _extract_frontmatter(content) + if frontmatter_text is None: + return False, "Invalid frontmatter format" + + frontmatter, error = _load_frontmatter(frontmatter_text) + if error: + return False, error + + unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS) + if unexpected_keys: + allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS)) + unexpected = ", ".join(unexpected_keys) + return ( + False, + f"Unexpected key(s) in SKILL.md frontmatter: {unexpected}. Allowed properties are: {allowed}", + ) + + if "name" not in frontmatter: + return False, "Missing 'name' in frontmatter" + if "description" not in frontmatter: + return False, "Missing 'description' in frontmatter" + + name = frontmatter["name"] + if not isinstance(name, str): + return False, f"Name must be a string, got {type(name).__name__}" + name_error = _validate_skill_name(name.strip(), skill_path.name) + if name_error: + return False, name_error + + description = frontmatter["description"] + if not isinstance(description, str): + return False, f"Description must be a string, got {type(description).__name__}" + description_error = _validate_description(description) + if description_error: + return False, description_error + + always = frontmatter.get("always") + if always is not None and not isinstance(always, bool): + return False, f"'always' must be a boolean, got {type(always).__name__}" + + for child in skill_path.iterdir(): + if child.name == "SKILL.md": + continue + if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS: + continue + if child.is_symlink(): + continue + return ( + False, + f"Unexpected file or directory in skill root: {child.name}. " + "Only SKILL.md, scripts/, references/, and assets/ are allowed.", + ) + + return True, "Skill is valid!" + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python quick_validate.py ") + sys.exit(1) + + valid, message = validate_skill(sys.argv[1]) + print(message) + sys.exit(0 if valid else 1) diff --git a/tests/test_skill_creator_scripts.py b/tests/test_skill_creator_scripts.py new file mode 100644 index 0000000..4207c6f --- /dev/null +++ b/tests/test_skill_creator_scripts.py @@ -0,0 +1,127 @@ +import importlib +import shutil +import sys +import zipfile +from pathlib import Path + + +SCRIPT_DIR = Path("nanobot/skills/skill-creator/scripts").resolve() +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) + +init_skill = importlib.import_module("init_skill") +package_skill = importlib.import_module("package_skill") +quick_validate = importlib.import_module("quick_validate") + + +def test_init_skill_creates_expected_files(tmp_path: Path) -> None: + skill_dir = init_skill.init_skill( + "demo-skill", + tmp_path, + ["scripts", "references", "assets"], + include_examples=True, + ) + + assert skill_dir == tmp_path / "demo-skill" + assert (skill_dir / "SKILL.md").exists() + assert (skill_dir / "scripts" / "example.py").exists() + assert (skill_dir / "references" / "api_reference.md").exists() + assert (skill_dir / "assets" / "example_asset.txt").exists() + + +def test_validate_skill_accepts_existing_skill_creator() -> None: + valid, message = quick_validate.validate_skill( + Path("nanobot/skills/skill-creator").resolve() + ) + + assert valid, message + + +def test_validate_skill_rejects_placeholder_description(tmp_path: Path) -> None: + skill_dir = tmp_path / "placeholder-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: placeholder-skill\n" + 'description: "[TODO: fill me in]"\n' + "---\n" + "# Placeholder\n", + encoding="utf-8", + ) + + valid, message = quick_validate.validate_skill(skill_dir) + + assert not valid + assert "TODO placeholder" in message + + +def test_validate_skill_rejects_root_files_outside_allowed_dirs(tmp_path: Path) -> None: + skill_dir = tmp_path / "bad-root-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: bad-root-skill\n" + "description: Valid description\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + (skill_dir / "README.md").write_text("extra\n", encoding="utf-8") + + valid, message = quick_validate.validate_skill(skill_dir) + + assert not valid + assert "Unexpected file or directory in skill root" in message + + +def test_package_skill_creates_archive(tmp_path: Path) -> None: + skill_dir = tmp_path / "package-me" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: package-me\n" + "description: Package this skill.\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + (scripts_dir / "helper.py").write_text("print('ok')\n", encoding="utf-8") + + archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist") + + assert archive_path == (tmp_path / "dist" / "package-me.skill") + assert archive_path.exists() + with zipfile.ZipFile(archive_path, "r") as archive: + names = set(archive.namelist()) + assert "package-me/SKILL.md" in names + assert "package-me/scripts/helper.py" in names + + +def test_package_skill_rejects_symlink(tmp_path: Path) -> None: + skill_dir = tmp_path / "symlink-skill" + skill_dir.mkdir() + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: symlink-skill\n" + "description: Reject symlinks during packaging.\n" + "---\n" + "# Skill\n", + encoding="utf-8", + ) + scripts_dir = skill_dir / "scripts" + scripts_dir.mkdir() + target = tmp_path / "outside.txt" + target.write_text("secret\n", encoding="utf-8") + link = scripts_dir / "outside.txt" + + try: + link.symlink_to(target) + except (OSError, NotImplementedError): + return + + archive_path = package_skill.package_skill(skill_dir, tmp_path / "dist") + + assert archive_path is None + assert not (tmp_path / "dist" / "symlink-skill.skill").exists()