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 01/77] 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 8571df2e634809b396b08b0967957078c15ede7f Mon Sep 17 00:00:00 2001 From: zerone0x Date: Sun, 1 Mar 2026 15:13:44 +0100 Subject: [PATCH 02/77] 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 ae788a17f8371de0d60b7b9d713bdb8261fa6cd2 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 2 Mar 2026 11:03:54 +0800 Subject: [PATCH 03/77] 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 04/77] 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 05/77] 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 06/77] 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 07/77] 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 0f1cc40b22997fd712c719a6dbc3a970491360e2 Mon Sep 17 00:00:00 2001 From: WufeiHalf Date: Tue, 3 Mar 2026 22:08:01 +0800 Subject: [PATCH 08/77] 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 5f7fb9c75ad1d3d442d4236607c827ad97a132fd Mon Sep 17 00:00:00 2001 From: cocolato Date: Tue, 3 Mar 2026 23:40:56 +0800 Subject: [PATCH 09/77] add missed dependency --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index a22053c..4199af1 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", + "openai>=2.8.0", ] [project.optional-dependencies] 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 10/77] 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 11/77] 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 12/77] 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 13/77] 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 14/77] 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 15/77] 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 16/77] 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 17/77] 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 18/77] 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 19/77] 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 20/77] 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 21/77] 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 22/77] 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 23/77] 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 24/77] 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 25/77] 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 26/77] 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 27/77] 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 28/77] 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 29/77] 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 30/77] 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 31/77] 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 32/77] 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 33/77] 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 34/77] 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 35/77] 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 36/77] 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 37/77] 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 38/77] 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 39/77] 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 40/77] 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 41/77] 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 42/77] 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 43/77] 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 44/77] 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 5ced08b1f23f5ef275465fbe3140f64d42c95ced Mon Sep 17 00:00:00 2001 From: pikaqqqqqq Date: Fri, 6 Mar 2026 01:54:00 +0800 Subject: [PATCH 45/77] 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 46/77] 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 47/77] 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 48/77] 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 49/77] 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 50/77] 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 51/77] 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 52/77] 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 53/77] 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 54/77] 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 55/77] 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 56/77] 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 57/77] 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 0b0f47f09fd37f1c354ba5a656b8f756d72a3382 Mon Sep 17 00:00:00 2001 From: Kunal Karmakar Date: Fri, 6 Mar 2026 10:37:16 +0000 Subject: [PATCH 58/77] 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 59/77] 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 60/77] 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 61/77] 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 62/77] 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 63/77] 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 fdd161d7b2f65d1564b23c445eaef56f85665ce3 Mon Sep 17 00:00:00 2001
From: fat-operator 
Date: Fri, 6 Mar 2026 23:36:54 +0000
Subject: [PATCH 64/77] 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 65/77] 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 66/77] 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 67/77] 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 68/77] 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 69/77] 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 70/77] 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 71/77] 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 72/77] 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 73/77] 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 74/77] 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 75/77] 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 76/77] 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 77/77] 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]