From 536ed60a05fcd1510d17e61d1e30a6a926f5d319 Mon Sep 17 00:00:00 2001 From: ruby childs Date: Wed, 18 Feb 2026 16:39:06 -0500 Subject: [PATCH 01/11] Fix safety guard false positive on 'format' in URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny pattern `\b(format|mkfs|diskpart)\b` incorrectly blocked commands containing "format" inside URLs (e.g. `curl https://wttr.in?format=3`) because `\b` fires at the boundary between `?` (non-word) and `f` (word). Split into two patterns: - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command (start of line or after shell operators) - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) Co-Authored-By: Claude Opus 4.6 --- nanobot/agent/tools/shell.py | 3 +- tests/test_exec_curl.py | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/test_exec_curl.py diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 18eff64..9c6f1f6 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -26,7 +26,8 @@ class ExecTool(Tool): r"\brm\s+-[rf]{1,2}\b", # rm -r, rm -rf, rm -fr r"\bdel\s+/[fq]\b", # del /f, del /q r"\brmdir\s+/s\b", # rmdir /s - r"\b(format|mkfs|diskpart)\b", # disk operations + r"(?:^|[;&|]\s*)format\b", # format (as standalone command only) + r"\b(mkfs|diskpart)\b", # disk operations r"\bdd\s+if=", # dd r">\s*/dev/sd", # write to disk r"\b(shutdown|reboot|poweroff)\b", # system power diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py new file mode 100644 index 0000000..2f53fcf --- /dev/null +++ b/tests/test_exec_curl.py @@ -0,0 +1,97 @@ +r"""Tests for ExecTool safety guard — format pattern false positive. + +The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside +URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word +character, so `\b` fires between `?` and `f`. + +The fix splits the pattern: + - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command + - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) +""" + +import re + +import pytest + +from nanobot.agent.tools.shell import ExecTool + + +# --- Guard regression: "format" in URLs must not be blocked --- + + +@pytest.mark.asyncio +async def test_curl_with_format_in_url_not_blocked(): + """curl with ?format= in URL should NOT be blocked by the guard.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute( + command="curl -s 'https://wttr.in/Brooklyn?format=3'" + ) + assert "blocked by safety guard" not in result + + +@pytest.mark.asyncio +async def test_curl_with_format_in_post_body_not_blocked(): + """curl with 'format=json' in POST body should NOT be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute( + command="curl -s -d 'format=json' https://httpbin.org/post" + ) + assert "blocked by safety guard" not in result + + +@pytest.mark.asyncio +async def test_curl_without_format_not_blocked(): + """Plain curl commands should pass the guard.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="curl -s https://httpbin.org/get") + assert "blocked by safety guard" not in result + + +# --- The guard still blocks actual format commands --- + + +@pytest.mark.asyncio +async def test_guard_blocks_standalone_format_command(): + """'format c:' as a standalone command must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="format c:") + assert "blocked by safety guard" in result + + +@pytest.mark.asyncio +async def test_guard_blocks_format_after_semicolon(): + """'echo hi; format c:' must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="echo hi; format c:") + assert "blocked by safety guard" in result + + +@pytest.mark.asyncio +async def test_guard_blocks_format_after_pipe(): + """'echo hi | format' must be blocked.""" + tool = ExecTool(working_dir="/tmp") + result = await tool.execute(command="echo hi | format") + assert "blocked by safety guard" in result + + +# --- Regex unit tests (no I/O) --- + + +def test_format_pattern_blocks_disk_commands(): + """The tightened pattern still catches actual format commands.""" + pattern = r"(?:^|[;&|]\s*)format\b" + + assert re.search(pattern, "format c:") + assert re.search(pattern, "echo hi; format c:") + assert re.search(pattern, "echo hi | format") + assert re.search(pattern, "cmd && format d:") + + +def test_format_pattern_allows_urls_and_flags(): + """The tightened pattern does NOT match format inside URLs or flags.""" + pattern = r"(?:^|[;&|]\s*)format\b" + + assert not re.search(pattern, "curl https://wttr.in?format=3") + assert not re.search(pattern, "echo --output-format=json") + assert not re.search(pattern, "curl -d 'format=json' https://api.example.com") + assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'") From 9789307dd691d469881b45ad7e0a7f655bab110d Mon Sep 17 00:00:00 2001 From: PiEgg Date: Thu, 19 Feb 2026 13:30:02 +0800 Subject: [PATCH 02/11] Fix Codex provider routing for GitHub Copilot models --- nanobot/config/schema.py | 25 +++++++++++++- nanobot/providers/litellm_provider.py | 13 +++++++- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/registry.py | 16 ++++++++- tests/test_commands.py | 38 ++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ce9634c..9558072 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -287,11 +287,34 @@ class Config(BaseSettings): from nanobot.providers.registry import PROVIDERS model_lower = (model or self.agents.defaults.model).lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + def _matches_model_prefix(spec_name: str) -> bool: + if not model_prefix: + return False + return normalized_prefix == spec_name + + def _keyword_matches(keyword: str) -> bool: + keyword_lower = keyword.lower() + return ( + keyword_lower in model_lower + or keyword_lower.replace("-", "_") in model_normalized + ) + + # Explicit provider prefix in model name wins over generic keyword matches. + # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and _matches_model_prefix(spec.name): + if spec.is_oauth or p.api_key: + return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(kw in model_lower for kw in spec.keywords): + if p and any(_keyword_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..3fec618 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -88,10 +88,21 @@ class LiteLLMProvider(LLMProvider): # Standard mode: auto-prefix for known providers spec = find_by_model(model) if spec and spec.litellm_prefix: + model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix) if not any(model.startswith(s) for s in spec.skip_prefixes): model = f"{spec.litellm_prefix}/{model}" - + return model + + @staticmethod + def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str: + """Normalize explicit provider prefixes like `github-copilot/...`.""" + if "/" not in model: + return model + prefix, remainder = model.split("/", 1) + if prefix.lower().replace("-", "_") != spec_name: + return model + return f"{canonical_prefix}/{remainder}" def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: """Apply model-specific parameter overrides from the registry.""" diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 5067438..2336e71 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -80,7 +80,7 @@ class OpenAICodexProvider(LLMProvider): def _strip_model_prefix(model: str) -> str: - if model.startswith("openai-codex/"): + if model.startswith("openai-codex/") or model.startswith("openai_codex/"): return model.split("/", 1)[1] return model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 49b735c..d986fe6 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -384,10 +384,24 @@ def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local — those are matched by api_key/api_base instead.""" model_lower = model.lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + # Prefer explicit provider prefix in model name. for spec in PROVIDERS: if spec.is_gateway or spec.is_local: continue - if any(kw in model_lower for kw in spec.keywords): + if model_prefix and normalized_prefix == spec.name: + return spec + + for spec in PROVIDERS: + if spec.is_gateway or spec.is_local: + continue + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized + for kw in spec.keywords + ): return spec return None diff --git a/tests/test_commands.py b/tests/test_commands.py index f5495fd..044d113 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,6 +6,10 @@ import pytest from typer.testing import CliRunner from nanobot.cli.commands import app +from nanobot.config.schema import Config +from nanobot.providers.litellm_provider import LiteLLMProvider +from nanobot.providers.openai_codex_provider import _strip_model_prefix +from nanobot.providers.registry import find_by_model runner = CliRunner() @@ -90,3 +94,37 @@ def test_onboard_existing_workspace_safe_create(mock_paths): assert "Created workspace" not in result.stdout assert "Created AGENTS.md" in result.stdout assert (workspace_dir / "AGENTS.md").exists() + + +def test_config_matches_github_copilot_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "github-copilot/gpt-5.3-codex" + + assert config.get_provider_name() == "github_copilot" + + +def test_config_matches_openai_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "openai-codex/gpt-5.1-codex" + + assert config.get_provider_name() == "openai_codex" + + +def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword(): + spec = find_by_model("github-copilot/gpt-5.3-codex") + + assert spec is not None + assert spec.name == "github_copilot" + + +def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix(): + provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex") + + resolved = provider._resolve_model("github-copilot/gpt-5.3-codex") + + assert resolved == "github_copilot/gpt-5.3-codex" + + +def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): + assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" + assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" From d08c02225551b5410e126c91b7224c773b5c9187 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Thu, 19 Feb 2026 16:31:00 +0800 Subject: [PATCH 03/11] feat(feishu): support sending images, audio, and files - Add image upload via im.v1.image.create API - Add file upload via im.v1.file.create API - Support sending images (.png, .jpg, .gif, etc.) as image messages - Support sending audio (.opus) as voice messages - Support sending other files as file messages - Refactor send() to handle media attachments before text content --- nanobot/channels/feishu.py | 179 ++++++++++++++++++++++++++++++------- 1 file changed, 149 insertions(+), 30 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index bc4a2b8..6f2881b 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -17,6 +17,10 @@ from nanobot.config.schema import FeishuConfig try: import lark_oapi as lark from lark_oapi.api.im.v1 import ( + CreateFileRequest, + CreateFileRequestBody, + CreateImageRequest, + CreateImageRequestBody, CreateMessageRequest, CreateMessageRequestBody, CreateMessageReactionRequest, @@ -284,48 +288,163 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] + # Image file extensions + _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} + + # Audio file extensions (Feishu only supports opus for audio messages) + _AUDIO_EXTS = {".opus"} + + # File type mapping for Feishu file upload API + _FILE_TYPE_MAP = { + ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", + ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", + } + + def _upload_image_sync(self, file_path: str) -> str | None: + """Upload an image to Feishu and return the image_key.""" + import os + try: + with open(file_path, "rb") as f: + request = CreateImageRequest.builder() \ + .request_body( + CreateImageRequestBody.builder() + .image_type("message") + .image(f) + .build() + ).build() + response = self._client.im.v1.image.create(request) + if response.success(): + image_key = response.data.image_key + logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") + return image_key + else: + logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}") + return None + except Exception as e: + logger.error(f"Error uploading image {file_path}: {e}") + return None + + def _upload_file_sync(self, file_path: str) -> str | None: + """Upload a file to Feishu and return the file_key.""" + import os + ext = os.path.splitext(file_path)[1].lower() + file_type = self._FILE_TYPE_MAP.get(ext, "stream") + file_name = os.path.basename(file_path) + try: + with open(file_path, "rb") as f: + request = CreateFileRequest.builder() \ + .request_body( + CreateFileRequestBody.builder() + .file_type(file_type) + .file_name(file_name) + .file(f) + .build() + ).build() + response = self._client.im.v1.file.create(request) + if response.success(): + file_key = response.data.file_key + logger.debug(f"Uploaded file {file_name}: {file_key}") + return file_key + else: + logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}") + return None + except Exception as e: + logger.error(f"Error uploading file {file_path}: {e}") + return None + + 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.""" + try: + request = CreateMessageRequest.builder() \ + .receive_id_type(receive_id_type) \ + .request_body( + CreateMessageRequestBody.builder() + .receive_id(receive_id) + .msg_type(msg_type) + .content(content) + .build() + ).build() + response = self._client.im.v1.message.create(request) + if not response.success(): + logger.error( + f"Failed to send Feishu {msg_type} message: code={response.code}, " + f"msg={response.msg}, log_id={response.get_log_id()}" + ) + return False + logger.debug(f"Feishu {msg_type} message sent to {receive_id}") + return True + except Exception as e: + logger.error(f"Error sending Feishu {msg_type} message: {e}") + return False + async def send(self, msg: OutboundMessage) -> None: - """Send a message through Feishu.""" + """Send a message through Feishu, including media (images/files) if present.""" if not self._client: logger.warning("Feishu client not initialized") return - + try: + import os + # Determine receive_id_type based on chat_id format # open_id starts with "ou_", chat_id starts with "oc_" if msg.chat_id.startswith("oc_"): receive_id_type = "chat_id" else: receive_id_type = "open_id" - - # Build card with markdown + table support - elements = self._build_card_elements(msg.content) - card = { - "config": {"wide_screen_mode": True}, - "elements": elements, - } - content = json.dumps(card, ensure_ascii=False) - - request = CreateMessageRequest.builder() \ - .receive_id_type(receive_id_type) \ - .request_body( - CreateMessageRequestBody.builder() - .receive_id(msg.chat_id) - .msg_type("interactive") - .content(content) - .build() - ).build() - - response = self._client.im.v1.message.create(request) - - if not response.success(): - logger.error( - f"Failed to send Feishu message: code={response.code}, " - f"msg={response.msg}, log_id={response.get_log_id()}" + + loop = asyncio.get_running_loop() + + # --- Send media attachments first --- + if msg.media: + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning(f"Media file not found: {file_path}") + continue + + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + # Upload and send as image + image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if image_key: + content = json.dumps({"image_key": image_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", content, + ) + elif ext in self._AUDIO_EXTS: + # Upload and send as audio (voice message) + file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if file_key: + content = json.dumps({"file_key": file_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "audio", content, + ) + else: + # Upload and send as file + file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) + if file_key: + content = json.dumps({"file_key": file_key}) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "file", content, + ) + + # --- Send text content (if any) --- + if msg.content and msg.content.strip(): + # Build card with markdown + table support + elements = self._build_card_elements(msg.content) + card = { + "config": {"wide_screen_mode": True}, + "elements": elements, + } + content = json.dumps(card, ensure_ascii=False) + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "interactive", content, ) - else: - logger.debug(f"Feishu message sent to {msg.chat_id}") - + except Exception as e: logger.error(f"Error sending Feishu message: {e}") From c86dbc9f45e4467d54ec0a56ce5597219071d8ee Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 10:27:11 -0300 Subject: [PATCH 04/11] fix: wait for killed process after shell timeout to prevent fd leaks When a shell command times out, process.kill() is called but the process object was never awaited after that. This leaves subprocess pipes undrained and file descriptors open. If many commands time out, fd leaks accumulate. Add a bounded wait (5s) after kill to let the process fully terminate and release its resources. --- nanobot/agent/tools/shell.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nanobot/agent/tools/shell.py b/nanobot/agent/tools/shell.py index 18eff64..ceac6b7 100644 --- a/nanobot/agent/tools/shell.py +++ b/nanobot/agent/tools/shell.py @@ -81,6 +81,12 @@ class ExecTool(Tool): ) except asyncio.TimeoutError: process.kill() + # Wait for the process to fully terminate so pipes are + # drained and file descriptors are released. + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + pass return f"Error: Command timed out after {self.timeout} seconds" output_parts = [] From d748e6eca33baf4b419c624b2a5e702b0d5b6b35 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:28:13 +0000 Subject: [PATCH 05/11] fix: pin dependency version ranges --- pyproject.toml | 54 +++++++++++++++++++++++++------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bbd6feb..64a884d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,37 +17,37 @@ classifiers = [ ] dependencies = [ - "typer>=0.9.0", - "litellm>=1.0.0", - "pydantic>=2.0.0", - "pydantic-settings>=2.0.0", - "websockets>=12.0", - "websocket-client>=1.6.0", - "httpx>=0.25.0", - "oauth-cli-kit>=0.1.1", - "loguru>=0.7.0", - "readability-lxml>=0.8.0", - "rich>=13.0.0", - "croniter>=2.0.0", - "dingtalk-stream>=0.4.0", - "python-telegram-bot[socks]>=21.0", - "lark-oapi>=1.0.0", - "socksio>=1.0.0", - "python-socketio>=5.11.0", - "msgpack>=1.0.8", - "slack-sdk>=3.26.0", - "slackify-markdown>=0.2.0", - "qq-botpy>=1.0.0", - "python-socks[asyncio]>=2.4.0", - "prompt-toolkit>=3.0.0", - "mcp>=1.0.0", - "json-repair>=0.30.0", + "typer>=0.20.0,<1.0.0", + "litellm>=1.81.5,<2.0.0", + "pydantic>=2.12.0,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "websockets>=16.0,<17.0", + "websocket-client>=1.9.0,<2.0.0", + "httpx>=0.28.0,<1.0.0", + "oauth-cli-kit>=0.1.3,<1.0.0", + "loguru>=0.7.3,<1.0.0", + "readability-lxml>=0.8.4,<1.0.0", + "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", + "lark-oapi>=1.5.0,<2.0.0", + "socksio>=1.0.0,<2.0.0", + "python-socketio>=5.16.0,<6.0.0", + "msgpack>=1.1.0,<2.0.0", + "slack-sdk>=3.39.0,<4.0.0", + "slackify-markdown>=0.2.0,<1.0.0", + "qq-botpy>=1.2.0,<2.0.0", + "python-socks[asyncio]>=2.8.0,<3.0.0", + "prompt-toolkit>=3.0.50,<4.0.0", + "mcp>=1.26.0,<2.0.0", + "json-repair>=0.57.0,<1.0.0", ] [project.optional-dependencies] dev = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", + "pytest>=9.0.0,<10.0.0", + "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", ] From 3890f1a7dd040050df64b11755d392c8b2689806 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:33:08 +0000 Subject: [PATCH 06/11] refactor(feishu): clean up send() and remove dead code --- nanobot/channels/feishu.py | 85 +++++++++++--------------------------- 1 file changed, 24 insertions(+), 61 deletions(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6f2881b..651d655 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -2,6 +2,7 @@ import asyncio import json +import os import re import threading from collections import OrderedDict @@ -267,7 +268,6 @@ class FeishuChannel(BaseChannel): before = protected[last_end:m.start()].strip() if before: elements.append({"tag": "markdown", "content": before}) - level = len(m.group(1)) text = m.group(2).strip() elements.append({ "tag": "div", @@ -288,13 +288,8 @@ class FeishuChannel(BaseChannel): return elements or [{"tag": "markdown", "content": content}] - # Image file extensions _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"} - - # Audio file extensions (Feishu only supports opus for audio messages) _AUDIO_EXTS = {".opus"} - - # File type mapping for Feishu file upload API _FILE_TYPE_MAP = { ".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc", ".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt", @@ -302,7 +297,6 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" - import os try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -326,7 +320,6 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" - import os ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -384,65 +377,35 @@ class FeishuChannel(BaseChannel): return try: - import os - - # Determine receive_id_type based on chat_id format - # open_id starts with "ou_", chat_id starts with "oc_" - if msg.chat_id.startswith("oc_"): - receive_id_type = "chat_id" - else: - receive_id_type = "open_id" - + receive_id_type = "chat_id" if msg.chat_id.startswith("oc_") else "open_id" loop = asyncio.get_running_loop() - # --- Send media attachments first --- - if msg.media: - for file_path in msg.media: - if not os.path.isfile(file_path): - logger.warning(f"Media file not found: {file_path}") - continue + for file_path in msg.media: + if not os.path.isfile(file_path): + logger.warning(f"Media file not found: {file_path}") + continue + ext = os.path.splitext(file_path)[1].lower() + if ext in self._IMAGE_EXTS: + key = await loop.run_in_executor(None, self._upload_image_sync, file_path) + if key: + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, "image", json.dumps({"image_key": key}), + ) + 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" + await loop.run_in_executor( + None, self._send_message_sync, + receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}), + ) - ext = os.path.splitext(file_path)[1].lower() - if ext in self._IMAGE_EXTS: - # Upload and send as image - image_key = await loop.run_in_executor(None, self._upload_image_sync, file_path) - if image_key: - content = json.dumps({"image_key": image_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "image", content, - ) - elif ext in self._AUDIO_EXTS: - # Upload and send as audio (voice message) - file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) - if file_key: - content = json.dumps({"file_key": file_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "audio", content, - ) - else: - # Upload and send as file - file_key = await loop.run_in_executor(None, self._upload_file_sync, file_path) - if file_key: - content = json.dumps({"file_key": file_key}) - await loop.run_in_executor( - None, self._send_message_sync, - receive_id_type, msg.chat_id, "file", content, - ) - - # --- Send text content (if any) --- if msg.content and msg.content.strip(): - # Build card with markdown + table support - elements = self._build_card_elements(msg.content) - card = { - "config": {"wide_screen_mode": True}, - "elements": elements, - } - content = json.dumps(card, ensure_ascii=False) + 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", content, + receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False), ) except Exception as e: From b11f0ce6a9bc8159ed1a7a6937c7c93c80a54733 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:39:44 +0000 Subject: [PATCH 07/11] fix: prefer explicit provider prefix over keyword match to fix Codex routing --- nanobot/config/schema.py | 21 ++++++--------------- nanobot/providers/registry.py | 16 +++++----------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9558072..6a1257e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -291,30 +291,21 @@ class Config(BaseSettings): model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") - def _matches_model_prefix(spec_name: str) -> bool: - if not model_prefix: - return False - return normalized_prefix == spec_name + def _kw_matches(kw: str) -> bool: + kw = kw.lower() + return kw in model_lower or kw.replace("-", "_") in model_normalized - def _keyword_matches(keyword: str) -> bool: - keyword_lower = keyword.lower() - return ( - keyword_lower in model_lower - or keyword_lower.replace("-", "_") in model_normalized - ) - - # Explicit provider prefix in model name wins over generic keyword matches. - # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex. for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and _matches_model_prefix(spec.name): + if p and model_prefix and normalized_prefix == spec.name: if spec.is_oauth or p.api_key: return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(_keyword_matches(kw) for kw in spec.keywords): + if p and any(_kw_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d986fe6..3071793 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -387,21 +387,15 @@ def find_by_model(model: str) -> ProviderSpec | None: model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") + std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local] - # Prefer explicit provider prefix in model name. - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue + # Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex. + for spec in std_specs: if model_prefix and normalized_prefix == spec.name: return spec - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue - if any( - kw in model_lower or kw.replace("-", "_") in model_normalized - for kw in spec.keywords - ): + for spec in std_specs: + if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): return spec return None From fbfb030a6eeabf6b3abf601f7cbe6514da876943 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:48:09 +0000 Subject: [PATCH 08/11] chore: remove network-dependent test file for shell guard --- tests/test_exec_curl.py | 97 ----------------------------------------- 1 file changed, 97 deletions(-) delete mode 100644 tests/test_exec_curl.py diff --git a/tests/test_exec_curl.py b/tests/test_exec_curl.py deleted file mode 100644 index 2f53fcf..0000000 --- a/tests/test_exec_curl.py +++ /dev/null @@ -1,97 +0,0 @@ -r"""Tests for ExecTool safety guard — format pattern false positive. - -The old deny pattern `\b(format|mkfs|diskpart)\b` matched "format" inside -URLs (e.g. `curl https://wttr.in?format=3`) because `?` is a non-word -character, so `\b` fires between `?` and `f`. - -The fix splits the pattern: - - `(?:^|[;&|]\s*)format\b` — only matches `format` as a standalone command - - `\b(mkfs|diskpart)\b` — kept as-is (unique enough to not false-positive) -""" - -import re - -import pytest - -from nanobot.agent.tools.shell import ExecTool - - -# --- Guard regression: "format" in URLs must not be blocked --- - - -@pytest.mark.asyncio -async def test_curl_with_format_in_url_not_blocked(): - """curl with ?format= in URL should NOT be blocked by the guard.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute( - command="curl -s 'https://wttr.in/Brooklyn?format=3'" - ) - assert "blocked by safety guard" not in result - - -@pytest.mark.asyncio -async def test_curl_with_format_in_post_body_not_blocked(): - """curl with 'format=json' in POST body should NOT be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute( - command="curl -s -d 'format=json' https://httpbin.org/post" - ) - assert "blocked by safety guard" not in result - - -@pytest.mark.asyncio -async def test_curl_without_format_not_blocked(): - """Plain curl commands should pass the guard.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="curl -s https://httpbin.org/get") - assert "blocked by safety guard" not in result - - -# --- The guard still blocks actual format commands --- - - -@pytest.mark.asyncio -async def test_guard_blocks_standalone_format_command(): - """'format c:' as a standalone command must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="format c:") - assert "blocked by safety guard" in result - - -@pytest.mark.asyncio -async def test_guard_blocks_format_after_semicolon(): - """'echo hi; format c:' must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="echo hi; format c:") - assert "blocked by safety guard" in result - - -@pytest.mark.asyncio -async def test_guard_blocks_format_after_pipe(): - """'echo hi | format' must be blocked.""" - tool = ExecTool(working_dir="/tmp") - result = await tool.execute(command="echo hi | format") - assert "blocked by safety guard" in result - - -# --- Regex unit tests (no I/O) --- - - -def test_format_pattern_blocks_disk_commands(): - """The tightened pattern still catches actual format commands.""" - pattern = r"(?:^|[;&|]\s*)format\b" - - assert re.search(pattern, "format c:") - assert re.search(pattern, "echo hi; format c:") - assert re.search(pattern, "echo hi | format") - assert re.search(pattern, "cmd && format d:") - - -def test_format_pattern_allows_urls_and_flags(): - """The tightened pattern does NOT match format inside URLs or flags.""" - pattern = r"(?:^|[;&|]\s*)format\b" - - assert not re.search(pattern, "curl https://wttr.in?format=3") - assert not re.search(pattern, "echo --output-format=json") - assert not re.search(pattern, "curl -d 'format=json' https://api.example.com") - assert not re.search(pattern, "python -c 'print(\"{:format}\".format(1))'") From 53b83a38e2dc44b91e7890594abb1bd2220a6b03 Mon Sep 17 00:00:00 2001 From: Nikolas de Hor Date: Thu, 19 Feb 2026 17:19:36 -0300 Subject: [PATCH 09/11] fix: use loguru native formatting to prevent KeyError on messages containing curly braces Closes #857 --- nanobot/agent/loop.py | 12 ++++++------ nanobot/agent/subagent.py | 4 ++-- nanobot/agent/tools/mcp.py | 2 +- nanobot/bus/queue.py | 2 +- nanobot/channels/dingtalk.py | 18 +++++++++--------- nanobot/channels/discord.py | 10 +++++----- nanobot/channels/email.py | 4 ++-- nanobot/channels/feishu.py | 26 +++++++++++++------------- nanobot/channels/manager.py | 6 +++--- nanobot/channels/mochat.py | 24 ++++++++++++------------ nanobot/channels/qq.py | 6 +++--- nanobot/channels/slack.py | 8 ++++---- nanobot/channels/telegram.py | 16 ++++++++-------- nanobot/channels/whatsapp.py | 10 +++++----- nanobot/cron/service.py | 4 ++-- nanobot/heartbeat/service.py | 4 ++-- nanobot/providers/transcription.py | 2 +- nanobot/session/manager.py | 2 +- 18 files changed, 80 insertions(+), 80 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index e5a5183..cbab5aa 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -219,7 +219,7 @@ class AgentLoop: for tool_call in response.tool_calls: tools_used.append(tool_call.name) args_str = json.dumps(tool_call.arguments, ensure_ascii=False) - logger.info(f"Tool call: {tool_call.name}({args_str[:200]})") + logger.info("Tool call: {}({})", tool_call.name, args_str[:200]) result = await self.tools.execute(tool_call.name, tool_call.arguments) messages = self.context.add_tool_result( messages, tool_call.id, tool_call.name, result @@ -247,7 +247,7 @@ class AgentLoop: if response: await self.bus.publish_outbound(response) except Exception as e: - logger.error(f"Error processing message: {e}") + logger.error("Error processing message: {}", e) await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, @@ -292,7 +292,7 @@ class AgentLoop: return await self._process_system_message(msg) preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content - logger.info(f"Processing message from {msg.channel}:{msg.sender_id}: {preview}") + logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview) key = session_key or msg.session_key session = self.sessions.get_or_create(key) @@ -344,7 +344,7 @@ class AgentLoop: final_content = "I've completed processing but have no response to give." preview = final_content[:120] + "..." if len(final_content) > 120 else final_content - logger.info(f"Response to {msg.channel}:{msg.sender_id}: {preview}") + logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) session.add_message("user", msg.content) session.add_message("assistant", final_content, @@ -469,7 +469,7 @@ Respond with ONLY valid JSON, no markdown fences.""" text = text.split("\n", 1)[-1].rsplit("```", 1)[0].strip() result = json_repair.loads(text) if not isinstance(result, dict): - logger.warning(f"Memory consolidation: unexpected response type, skipping. Response: {text[:200]}") + logger.warning("Memory consolidation: unexpected response type, skipping. Response: {}", text[:200]) return if entry := result.get("history_entry"): @@ -484,7 +484,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = len(session.messages) - keep_count logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}") except Exception as e: - logger.error(f"Memory consolidation failed: {e}") + logger.error("Memory consolidation failed: {}", e) async def process_direct( self, diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index 203836a..ae0e492 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -160,7 +160,7 @@ class SubagentManager: # Execute tools for tool_call in response.tool_calls: args_str = json.dumps(tool_call.arguments) - logger.debug(f"Subagent [{task_id}] executing: {tool_call.name} with arguments: {args_str}") + logger.debug("Subagent [{}] executing: {} with arguments: {}", task_id, tool_call.name, args_str) result = await tools.execute(tool_call.name, tool_call.arguments) messages.append({ "role": "tool", @@ -180,7 +180,7 @@ class SubagentManager: except Exception as e: error_msg = f"Error: {str(e)}" - logger.error(f"Subagent [{task_id}] failed: {e}") + logger.error("Subagent [{}] failed: {}", task_id, e) await self._announce_result(task_id, label, task, error_msg, origin, "error") async def _announce_result( diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 1c8eac4..4e61923 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -77,4 +77,4 @@ async def connect_mcp_servers( logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") except Exception as e: - logger.error(f"MCP server '{name}': failed to connect: {e}") + logger.error("MCP server '{}': failed to connect: {}", name, e) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 4123d06..554c0ec 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -62,7 +62,7 @@ class MessageBus: try: await callback(msg) except Exception as e: - logger.error(f"Error dispatching to {msg.channel}: {e}") + logger.error("Error dispatching to {}: {}", msg.channel, e) except asyncio.TimeoutError: continue diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 4a8cdd9..3ac233f 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -65,7 +65,7 @@ class NanobotDingTalkHandler(CallbackHandler): sender_id = chatbot_msg.sender_staff_id or chatbot_msg.sender_id sender_name = chatbot_msg.sender_nick or "Unknown" - logger.info(f"Received DingTalk message from {sender_name} ({sender_id}): {content}") + logger.info("Received DingTalk message from {} ({}): {}", sender_name, sender_id, content) # Forward to Nanobot via _on_message (non-blocking). # Store reference to prevent GC before task completes. @@ -78,7 +78,7 @@ class NanobotDingTalkHandler(CallbackHandler): return AckMessage.STATUS_OK, "OK" except Exception as e: - logger.error(f"Error processing DingTalk message: {e}") + logger.error("Error processing DingTalk message: {}", e) # Return OK to avoid retry loop from DingTalk server return AckMessage.STATUS_OK, "Error" @@ -142,13 +142,13 @@ class DingTalkChannel(BaseChannel): try: await self._client.start() except Exception as e: - logger.warning(f"DingTalk stream error: {e}") + logger.warning("DingTalk stream error: {}", e) if self._running: logger.info("Reconnecting DingTalk stream in 5 seconds...") await asyncio.sleep(5) except Exception as e: - logger.exception(f"Failed to start DingTalk channel: {e}") + logger.exception("Failed to start DingTalk channel: {}", e) async def stop(self) -> None: """Stop the DingTalk bot.""" @@ -186,7 +186,7 @@ class DingTalkChannel(BaseChannel): self._token_expiry = time.time() + int(res_data.get("expireIn", 7200)) - 60 return self._access_token except Exception as e: - logger.error(f"Failed to get DingTalk access token: {e}") + logger.error("Failed to get DingTalk access token: {}", e) return None async def send(self, msg: OutboundMessage) -> None: @@ -218,11 +218,11 @@ class DingTalkChannel(BaseChannel): try: resp = await self._http.post(url, json=data, headers=headers) if resp.status_code != 200: - logger.error(f"DingTalk send failed: {resp.text}") + logger.error("DingTalk send failed: {}", resp.text) else: logger.debug(f"DingTalk message sent to {msg.chat_id}") except Exception as e: - logger.error(f"Error sending DingTalk message: {e}") + logger.error("Error sending DingTalk message: {}", e) async def _on_message(self, content: str, sender_id: str, sender_name: str) -> None: """Handle incoming message (called by NanobotDingTalkHandler). @@ -231,7 +231,7 @@ class DingTalkChannel(BaseChannel): permission checks before publishing to the bus. """ try: - logger.info(f"DingTalk inbound: {content} from {sender_name}") + logger.info("DingTalk inbound: {} from {}", content, sender_name) await self._handle_message( sender_id=sender_id, chat_id=sender_id, # For private chat, chat_id == sender_id @@ -242,4 +242,4 @@ class DingTalkChannel(BaseChannel): }, ) except Exception as e: - logger.error(f"Error publishing DingTalk message: {e}") + logger.error("Error publishing DingTalk message: {}", e) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index a76d6ac..ee54eed 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -51,7 +51,7 @@ class DiscordChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Discord gateway error: {e}") + logger.warning("Discord gateway error: {}", e) if self._running: logger.info("Reconnecting to Discord gateway in 5 seconds...") await asyncio.sleep(5) @@ -101,7 +101,7 @@ class DiscordChannel(BaseChannel): return except Exception as e: if attempt == 2: - logger.error(f"Error sending Discord message: {e}") + logger.error("Error sending Discord message: {}", e) else: await asyncio.sleep(1) finally: @@ -116,7 +116,7 @@ class DiscordChannel(BaseChannel): try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}") + logger.warning("Invalid JSON from Discord gateway: {}", raw[:100]) continue op = data.get("op") @@ -175,7 +175,7 @@ class DiscordChannel(BaseChannel): try: await self._ws.send(json.dumps(payload)) except Exception as e: - logger.warning(f"Discord heartbeat failed: {e}") + logger.warning("Discord heartbeat failed: {}", e) break await asyncio.sleep(interval_s) @@ -219,7 +219,7 @@ class DiscordChannel(BaseChannel): media_paths.append(str(file_path)) content_parts.append(f"[attachment: {file_path}]") except Exception as e: - logger.warning(f"Failed to download Discord attachment: {e}") + logger.warning("Failed to download Discord attachment: {}", e) content_parts.append(f"[attachment: {filename} - download failed]") reply_to = (payload.get("referenced_message") or {}).get("id") diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 0e47067..8a1ee79 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -94,7 +94,7 @@ class EmailChannel(BaseChannel): metadata=item.get("metadata", {}), ) except Exception as e: - logger.error(f"Email polling error: {e}") + logger.error("Email polling error: {}", e) await asyncio.sleep(poll_seconds) @@ -143,7 +143,7 @@ class EmailChannel(BaseChannel): try: await asyncio.to_thread(self._smtp_send, email_msg) except Exception as e: - logger.error(f"Error sending email to {to_addr}: {e}") + logger.error("Error sending email to {}: {}", to_addr, e) raise def _validate_config(self) -> bool: diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 651d655..6f62202 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -156,7 +156,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.start() except Exception as e: - logger.warning(f"Feishu WebSocket error: {e}") + logger.warning("Feishu WebSocket error: {}", e) if self._running: import time; time.sleep(5) @@ -177,7 +177,7 @@ class FeishuChannel(BaseChannel): try: self._ws_client.stop() except Exception as e: - logger.warning(f"Error stopping WebSocket client: {e}") + logger.warning("Error stopping WebSocket client: {}", e) logger.info("Feishu bot stopped") def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: @@ -194,11 +194,11 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message_reaction.create(request) if not response.success(): - logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}") + logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: logger.debug(f"Added {emoji_type} reaction to message {message_id}") except Exception as e: - logger.warning(f"Error adding reaction: {e}") + logger.warning("Error adding reaction: {}", e) async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None: """ @@ -312,10 +312,10 @@ class FeishuChannel(BaseChannel): logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") return image_key else: - logger.error(f"Failed to upload image: code={response.code}, msg={response.msg}") + logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.error(f"Error uploading image {file_path}: {e}") + logger.error("Error uploading image {}: {}", file_path, e) return None def _upload_file_sync(self, file_path: str) -> str | None: @@ -339,10 +339,10 @@ class FeishuChannel(BaseChannel): logger.debug(f"Uploaded file {file_name}: {file_key}") return file_key else: - logger.error(f"Failed to upload file: code={response.code}, msg={response.msg}") + logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) return None except Exception as e: - logger.error(f"Error uploading file {file_path}: {e}") + logger.error("Error uploading file {}: {}", file_path, e) return None def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: @@ -360,14 +360,14 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.message.create(request) if not response.success(): logger.error( - f"Failed to send Feishu {msg_type} message: code={response.code}, " - f"msg={response.msg}, log_id={response.get_log_id()}" + "Failed to send Feishu {} message: code={}, msg={}, log_id={}", + msg_type, response.code, response.msg, response.get_log_id() ) return False logger.debug(f"Feishu {msg_type} message sent to {receive_id}") return True except Exception as e: - logger.error(f"Error sending Feishu {msg_type} message: {e}") + logger.error("Error sending Feishu {} message: {}", msg_type, e) return False async def send(self, msg: OutboundMessage) -> None: @@ -409,7 +409,7 @@ class FeishuChannel(BaseChannel): ) except Exception as e: - logger.error(f"Error sending Feishu message: {e}") + logger.error("Error sending Feishu message: {}", e) def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None: """ @@ -481,4 +481,4 @@ class FeishuChannel(BaseChannel): ) except Exception as e: - logger.error(f"Error processing Feishu message: {e}") + logger.error("Error processing Feishu message: {}", e) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index e860d26..3e714c3 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -142,7 +142,7 @@ class ChannelManager: try: await channel.start() except Exception as e: - logger.error(f"Failed to start channel {name}: {e}") + logger.error("Failed to start channel {}: {}", name, e) async def start_all(self) -> None: """Start all channels and the outbound dispatcher.""" @@ -180,7 +180,7 @@ class ChannelManager: await channel.stop() logger.info(f"Stopped {name} channel") except Exception as e: - logger.error(f"Error stopping {name}: {e}") + logger.error("Error stopping {}: {}", name, e) async def _dispatch_outbound(self) -> None: """Dispatch outbound messages to the appropriate channel.""" @@ -198,7 +198,7 @@ class ChannelManager: try: await channel.send(msg) except Exception as e: - logger.error(f"Error sending to {msg.channel}: {e}") + logger.error("Error sending to {}: {}", msg.channel, e) else: logger.warning(f"Unknown channel: {msg.channel}") diff --git a/nanobot/channels/mochat.py b/nanobot/channels/mochat.py index 30c3dbf..e762dfd 100644 --- a/nanobot/channels/mochat.py +++ b/nanobot/channels/mochat.py @@ -322,7 +322,7 @@ class MochatChannel(BaseChannel): await self._api_send("/api/claw/sessions/send", "sessionId", target.id, content, msg.reply_to) except Exception as e: - logger.error(f"Failed to send Mochat message: {e}") + logger.error("Failed to send Mochat message: {}", e) # ---- config / init helpers --------------------------------------------- @@ -380,7 +380,7 @@ class MochatChannel(BaseChannel): @client.event async def connect_error(data: Any) -> None: - logger.error(f"Mochat websocket connect error: {data}") + logger.error("Mochat websocket connect error: {}", data) @client.on("claw.session.events") async def on_session_events(payload: dict[str, Any]) -> None: @@ -407,7 +407,7 @@ class MochatChannel(BaseChannel): ) return True except Exception as e: - logger.error(f"Failed to connect Mochat websocket: {e}") + logger.error("Failed to connect Mochat websocket: {}", e) try: await client.disconnect() except Exception: @@ -444,7 +444,7 @@ class MochatChannel(BaseChannel): "limit": self.config.watch_limit, }) if not ack.get("result"): - logger.error(f"Mochat subscribeSessions failed: {ack.get('message', 'unknown error')}") + logger.error("Mochat subscribeSessions failed: {}", ack.get('message', 'unknown error')) return False data = ack.get("data") @@ -466,7 +466,7 @@ class MochatChannel(BaseChannel): return True ack = await self._socket_call("com.claw.im.subscribePanels", {"panelIds": panel_ids}) if not ack.get("result"): - logger.error(f"Mochat subscribePanels failed: {ack.get('message', 'unknown error')}") + logger.error("Mochat subscribePanels failed: {}", ack.get('message', 'unknown error')) return False return True @@ -488,7 +488,7 @@ class MochatChannel(BaseChannel): try: await self._refresh_targets(subscribe_new=self._ws_ready) except Exception as e: - logger.warning(f"Mochat refresh failed: {e}") + logger.warning("Mochat refresh failed: {}", e) if self._fallback_mode: await self._ensure_fallback_workers() @@ -502,7 +502,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/sessions/list", {}) except Exception as e: - logger.warning(f"Mochat listSessions failed: {e}") + logger.warning("Mochat listSessions failed: {}", e) return sessions = response.get("sessions") @@ -536,7 +536,7 @@ class MochatChannel(BaseChannel): try: response = await self._post_json("/api/claw/groups/get", {}) except Exception as e: - logger.warning(f"Mochat getWorkspaceGroup failed: {e}") + logger.warning("Mochat getWorkspaceGroup failed: {}", e) return raw_panels = response.get("panels") @@ -598,7 +598,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Mochat watch fallback error ({session_id}): {e}") + logger.warning("Mochat watch fallback error ({}): {}", session_id, e) await asyncio.sleep(max(0.1, self.config.retry_delay_ms / 1000.0)) async def _panel_poll_worker(self, panel_id: str) -> None: @@ -625,7 +625,7 @@ class MochatChannel(BaseChannel): except asyncio.CancelledError: break except Exception as e: - logger.warning(f"Mochat panel polling error ({panel_id}): {e}") + logger.warning("Mochat panel polling error ({}): {}", panel_id, e) await asyncio.sleep(sleep_s) # ---- inbound event processing ------------------------------------------ @@ -836,7 +836,7 @@ class MochatChannel(BaseChannel): try: data = json.loads(self._cursor_path.read_text("utf-8")) except Exception as e: - logger.warning(f"Failed to read Mochat cursor file: {e}") + logger.warning("Failed to read Mochat cursor file: {}", e) return cursors = data.get("cursors") if isinstance(data, dict) else None if isinstance(cursors, dict): @@ -852,7 +852,7 @@ class MochatChannel(BaseChannel): "cursors": self._session_cursor, }, ensure_ascii=False, indent=2) + "\n", "utf-8") except Exception as e: - logger.warning(f"Failed to save Mochat cursor file: {e}") + logger.warning("Failed to save Mochat cursor file: {}", e) # ---- HTTP helpers ------------------------------------------------------ diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 0e8fe66..1d00bc7 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -80,7 +80,7 @@ class QQChannel(BaseChannel): try: await self._client.start(appid=self.config.app_id, secret=self.config.secret) except Exception as e: - logger.warning(f"QQ bot error: {e}") + logger.warning("QQ bot error: {}", e) if self._running: logger.info("Reconnecting QQ bot in 5 seconds...") await asyncio.sleep(5) @@ -108,7 +108,7 @@ class QQChannel(BaseChannel): content=msg.content, ) except Exception as e: - logger.error(f"Error sending QQ message: {e}") + logger.error("Error sending QQ message: {}", e) async def _on_message(self, data: "C2CMessage") -> None: """Handle incoming message from QQ.""" @@ -131,4 +131,4 @@ class QQChannel(BaseChannel): metadata={"message_id": data.id}, ) except Exception as e: - logger.error(f"Error handling QQ message: {e}") + logger.error("Error handling QQ message: {}", e) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index dca5055..7dd2971 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -55,7 +55,7 @@ class SlackChannel(BaseChannel): self._bot_user_id = auth.get("user_id") logger.info(f"Slack bot connected as {self._bot_user_id}") except Exception as e: - logger.warning(f"Slack auth_test failed: {e}") + logger.warning("Slack auth_test failed: {}", e) logger.info("Starting Slack Socket Mode client...") await self._socket_client.connect() @@ -70,7 +70,7 @@ class SlackChannel(BaseChannel): try: await self._socket_client.close() except Exception as e: - logger.warning(f"Slack socket close failed: {e}") + logger.warning("Slack socket close failed: {}", e) self._socket_client = None async def send(self, msg: OutboundMessage) -> None: @@ -90,7 +90,7 @@ class SlackChannel(BaseChannel): thread_ts=thread_ts if use_thread else None, ) except Exception as e: - logger.error(f"Error sending Slack message: {e}") + logger.error("Error sending Slack message: {}", e) async def _on_socket_request( self, @@ -164,7 +164,7 @@ class SlackChannel(BaseChannel): timestamp=event.get("ts"), ) except Exception as e: - logger.debug(f"Slack reactions_add failed: {e}") + logger.debug("Slack reactions_add failed: {}", e) await self._handle_message( sender_id=sender_id, diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 39924b3..42db489 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -171,7 +171,7 @@ class TelegramChannel(BaseChannel): await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: - logger.warning(f"Failed to register bot commands: {e}") + logger.warning("Failed to register bot commands: {}", e) # Start polling (this runs until stopped) await self._app.updater.start_polling( @@ -238,7 +238,7 @@ class TelegramChannel(BaseChannel): await sender(chat_id=chat_id, **{param: f}) except Exception as e: filename = media_path.rsplit("/", 1)[-1] - logger.error(f"Failed to send media {media_path}: {e}") + logger.error("Failed to send media {}: {}", media_path, e) await self._app.bot.send_message(chat_id=chat_id, text=f"[Failed to send: {filename}]") # Send text content @@ -248,11 +248,11 @@ class TelegramChannel(BaseChannel): html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message(chat_id=chat_id, text=html, parse_mode="HTML") except Exception as e: - logger.warning(f"HTML parse failed, falling back to plain text: {e}") + logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message(chat_id=chat_id, text=chunk) except Exception as e2: - logger.error(f"Error sending Telegram message: {e2}") + logger.error("Error sending Telegram message: {}", e2) async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" @@ -353,12 +353,12 @@ class TelegramChannel(BaseChannel): logger.debug(f"Downloaded {media_type} to {file_path}") except Exception as e: - logger.error(f"Failed to download media: {e}") + logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") content = "\n".join(content_parts) if content_parts else "[empty message]" - logger.debug(f"Telegram message from {sender_id}: {content[:50]}...") + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) str_chat_id = str(chat_id) @@ -401,11 +401,11 @@ class TelegramChannel(BaseChannel): except asyncio.CancelledError: pass except Exception as e: - logger.debug(f"Typing indicator stopped for {chat_id}: {e}") + logger.debug("Typing indicator stopped for {}: {}", chat_id, e) async def _on_error(self, update: object, context: ContextTypes.DEFAULT_TYPE) -> None: """Log polling / handler errors instead of silently swallowing them.""" - logger.error(f"Telegram error: {context.error}") + logger.error("Telegram error: {}", context.error) def _get_extension(self, media_type: str, mime_type: str | None) -> str: """Get file extension based on media type.""" diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 0cf2dd7..4d12360 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -53,14 +53,14 @@ class WhatsAppChannel(BaseChannel): try: await self._handle_bridge_message(message) except Exception as e: - logger.error(f"Error handling bridge message: {e}") + logger.error("Error handling bridge message: {}", e) except asyncio.CancelledError: break except Exception as e: self._connected = False self._ws = None - logger.warning(f"WhatsApp bridge connection error: {e}") + logger.warning("WhatsApp bridge connection error: {}", e) if self._running: logger.info("Reconnecting in 5 seconds...") @@ -89,14 +89,14 @@ class WhatsAppChannel(BaseChannel): } await self._ws.send(json.dumps(payload)) except Exception as e: - logger.error(f"Error sending WhatsApp message: {e}") + logger.error("Error sending WhatsApp message: {}", e) async def _handle_bridge_message(self, raw: str) -> None: """Handle a message from the bridge.""" try: data = json.loads(raw) except json.JSONDecodeError: - logger.warning(f"Invalid JSON from bridge: {raw[:100]}") + logger.warning("Invalid JSON from bridge: {}", raw[:100]) return msg_type = data.get("type") @@ -145,4 +145,4 @@ class WhatsAppChannel(BaseChannel): logger.info("Scan QR code in the bridge terminal to connect WhatsApp") elif msg_type == "error": - logger.error(f"WhatsApp bridge error: {data.get('error')}") + logger.error("WhatsApp bridge error: {}", data.get('error')) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index 14666e8..d2b9ef7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -99,7 +99,7 @@ class CronService: )) self._store = CronStore(jobs=jobs) except Exception as e: - logger.warning(f"Failed to load cron store: {e}") + logger.warning("Failed to load cron store: {}", e) self._store = CronStore() else: self._store = CronStore() @@ -236,7 +236,7 @@ class CronService: except Exception as e: job.state.last_status = "error" job.state.last_error = str(e) - logger.error(f"Cron: job '{job.name}' failed: {e}") + logger.error("Cron: job '{}' failed: {}", job.name, e) job.state.last_run_at_ms = start_ms job.updated_at_ms = _now_ms() diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 221ed27..8bdc78f 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -97,7 +97,7 @@ class HeartbeatService: except asyncio.CancelledError: break except Exception as e: - logger.error(f"Heartbeat error: {e}") + logger.error("Heartbeat error: {}", e) async def _tick(self) -> None: """Execute a single heartbeat tick.""" @@ -121,7 +121,7 @@ class HeartbeatService: logger.info(f"Heartbeat: completed task") except Exception as e: - logger.error(f"Heartbeat execution failed: {e}") + logger.error("Heartbeat execution failed: {}", e) async def trigger_now(self) -> str | None: """Manually trigger a heartbeat.""" diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index 8ce909b..eb5969d 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -61,5 +61,5 @@ class GroqTranscriptionProvider: return data.get("text", "") except Exception as e: - logger.error(f"Groq transcription error: {e}") + logger.error("Groq transcription error: {}", e) return "" diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 752fce4..44dcecb 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -144,7 +144,7 @@ class SessionManager: last_consolidated=last_consolidated ) except Exception as e: - logger.warning(f"Failed to load session {key}: {e}") + logger.warning("Failed to load session {}: {}", key, e) return None def save(self, session: Session) -> None: From 0001f286b578b6ecf9634b47c6605978038e5a61 Mon Sep 17 00:00:00 2001 From: AlexanderMerkel Date: Thu, 19 Feb 2026 19:00:25 -0700 Subject: [PATCH 10/11] fix: remove dead pub/sub code from MessageBus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `subscribe_outbound()`, `dispatch_outbound()`, and `stop()` have zero callers — `ChannelManager._dispatch_outbound()` handles all outbound routing via `consume_outbound()` directly. Remove the dead methods and their unused imports (`Callable`, `Awaitable`, `logger`). Co-Authored-By: Claude Opus 4.6 --- nanobot/bus/queue.py | 53 +++++++------------------------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/nanobot/bus/queue.py b/nanobot/bus/queue.py index 4123d06..7c0616f 100644 --- a/nanobot/bus/queue.py +++ b/nanobot/bus/queue.py @@ -1,9 +1,6 @@ """Async message queue for decoupled channel-agent communication.""" import asyncio -from typing import Callable, Awaitable - -from loguru import logger from nanobot.bus.events import InboundMessage, OutboundMessage @@ -11,70 +8,36 @@ from nanobot.bus.events import InboundMessage, OutboundMessage class MessageBus: """ Async message bus that decouples chat channels from the agent core. - + Channels push messages to the inbound queue, and the agent processes them and pushes responses to the outbound queue. """ - + def __init__(self): self.inbound: asyncio.Queue[InboundMessage] = asyncio.Queue() self.outbound: asyncio.Queue[OutboundMessage] = asyncio.Queue() - self._outbound_subscribers: dict[str, list[Callable[[OutboundMessage], Awaitable[None]]]] = {} - self._running = False - + async def publish_inbound(self, msg: InboundMessage) -> None: """Publish a message from a channel to the agent.""" await self.inbound.put(msg) - + async def consume_inbound(self) -> InboundMessage: """Consume the next inbound message (blocks until available).""" return await self.inbound.get() - + async def publish_outbound(self, msg: OutboundMessage) -> None: """Publish a response from the agent to channels.""" await self.outbound.put(msg) - + async def consume_outbound(self) -> OutboundMessage: """Consume the next outbound message (blocks until available).""" return await self.outbound.get() - - def subscribe_outbound( - self, - channel: str, - callback: Callable[[OutboundMessage], Awaitable[None]] - ) -> None: - """Subscribe to outbound messages for a specific channel.""" - if channel not in self._outbound_subscribers: - self._outbound_subscribers[channel] = [] - self._outbound_subscribers[channel].append(callback) - - async def dispatch_outbound(self) -> None: - """ - Dispatch outbound messages to subscribed channels. - Run this as a background task. - """ - self._running = True - while self._running: - try: - msg = await asyncio.wait_for(self.outbound.get(), timeout=1.0) - subscribers = self._outbound_subscribers.get(msg.channel, []) - for callback in subscribers: - try: - await callback(msg) - except Exception as e: - logger.error(f"Error dispatching to {msg.channel}: {e}") - except asyncio.TimeoutError: - continue - - def stop(self) -> None: - """Stop the dispatcher loop.""" - self._running = False - + @property def inbound_size(self) -> int: """Number of pending inbound messages.""" return self.inbound.qsize() - + @property def outbound_size(self) -> int: """Number of pending outbound messages.""" From 37252a4226214367abea1cef0fae4591b2dc00c4 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 20 Feb 2026 07:55:34 +0000 Subject: [PATCH 11/11] fix: complete loguru native formatting migration across all files --- nanobot/agent/loop.py | 12 ++++++------ nanobot/agent/subagent.py | 8 ++++---- nanobot/agent/tools/mcp.py | 6 +++--- nanobot/channels/dingtalk.py | 2 +- nanobot/channels/discord.py | 2 +- nanobot/channels/email.py | 2 +- nanobot/channels/feishu.py | 10 +++++----- nanobot/channels/manager.py | 24 ++++++++++++------------ nanobot/channels/qq.py | 2 +- nanobot/channels/slack.py | 4 ++-- nanobot/channels/telegram.py | 8 ++++---- nanobot/channels/whatsapp.py | 8 ++++---- nanobot/cron/service.py | 10 +++++----- nanobot/heartbeat/service.py | 4 ++-- nanobot/providers/transcription.py | 2 +- nanobot/session/manager.py | 2 +- 16 files changed, 53 insertions(+), 53 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index cbab5aa..1620cb0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -365,7 +365,7 @@ class AgentLoop: The chat_id field contains "original_channel:original_chat_id" to route the response back to the correct destination. """ - logger.info(f"Processing system message from {msg.sender_id}") + logger.info("Processing system message from {}", msg.sender_id) # Parse origin from chat_id (format: "channel:chat_id") if ":" in msg.chat_id: @@ -413,22 +413,22 @@ class AgentLoop: if archive_all: old_messages = session.messages keep_count = 0 - logger.info(f"Memory consolidation (archive_all): {len(session.messages)} total messages archived") + logger.info("Memory consolidation (archive_all): {} total messages archived", len(session.messages)) else: keep_count = self.memory_window // 2 if len(session.messages) <= keep_count: - logger.debug(f"Session {session.key}: No consolidation needed (messages={len(session.messages)}, keep={keep_count})") + logger.debug("Session {}: No consolidation needed (messages={}, keep={})", session.key, len(session.messages), keep_count) return messages_to_process = len(session.messages) - session.last_consolidated if messages_to_process <= 0: - logger.debug(f"Session {session.key}: No new messages to consolidate (last_consolidated={session.last_consolidated}, total={len(session.messages)})") + logger.debug("Session {}: No new messages to consolidate (last_consolidated={}, total={})", session.key, session.last_consolidated, len(session.messages)) return old_messages = session.messages[session.last_consolidated:-keep_count] if not old_messages: return - logger.info(f"Memory consolidation started: {len(session.messages)} total, {len(old_messages)} new to consolidate, {keep_count} keep") + logger.info("Memory consolidation started: {} total, {} new to consolidate, {} keep", len(session.messages), len(old_messages), keep_count) lines = [] for m in old_messages: @@ -482,7 +482,7 @@ Respond with ONLY valid JSON, no markdown fences.""" session.last_consolidated = 0 else: session.last_consolidated = len(session.messages) - keep_count - logger.info(f"Memory consolidation done: {len(session.messages)} messages, last_consolidated={session.last_consolidated}") + logger.info("Memory consolidation done: {} messages, last_consolidated={}", len(session.messages), session.last_consolidated) except Exception as e: logger.error("Memory consolidation failed: {}", e) diff --git a/nanobot/agent/subagent.py b/nanobot/agent/subagent.py index ae0e492..7d48cc4 100644 --- a/nanobot/agent/subagent.py +++ b/nanobot/agent/subagent.py @@ -86,7 +86,7 @@ class SubagentManager: # Cleanup when done bg_task.add_done_callback(lambda _: self._running_tasks.pop(task_id, None)) - logger.info(f"Spawned subagent [{task_id}]: {display_label}") + logger.info("Spawned subagent [{}]: {}", task_id, display_label) return f"Subagent [{display_label}] started (id: {task_id}). I'll notify you when it completes." async def _run_subagent( @@ -97,7 +97,7 @@ class SubagentManager: origin: dict[str, str], ) -> None: """Execute the subagent task and announce the result.""" - logger.info(f"Subagent [{task_id}] starting task: {label}") + logger.info("Subagent [{}] starting task: {}", task_id, label) try: # Build subagent tools (no message tool, no spawn tool) @@ -175,7 +175,7 @@ class SubagentManager: if final_result is None: final_result = "Task completed but no final response was generated." - logger.info(f"Subagent [{task_id}] completed successfully") + logger.info("Subagent [{}] completed successfully", task_id) await self._announce_result(task_id, label, task, final_result, origin, "ok") except Exception as e: @@ -213,7 +213,7 @@ Summarize this naturally for the user. Keep it brief (1-2 sentences). Do not men ) await self.bus.publish_inbound(msg) - logger.debug(f"Subagent [{task_id}] announced result to {origin['channel']}:{origin['chat_id']}") + logger.debug("Subagent [{}] announced result to {}:{}", task_id, origin['channel'], origin['chat_id']) def _build_subagent_prompt(self, task: str) -> str: """Build a focused system prompt for the subagent.""" diff --git a/nanobot/agent/tools/mcp.py b/nanobot/agent/tools/mcp.py index 4e61923..7d9033d 100644 --- a/nanobot/agent/tools/mcp.py +++ b/nanobot/agent/tools/mcp.py @@ -63,7 +63,7 @@ async def connect_mcp_servers( streamable_http_client(cfg.url) ) else: - logger.warning(f"MCP server '{name}': no command or url configured, skipping") + logger.warning("MCP server '{}': no command or url configured, skipping", name) continue session = await stack.enter_async_context(ClientSession(read, write)) @@ -73,8 +73,8 @@ async def connect_mcp_servers( for tool_def in tools.tools: wrapper = MCPToolWrapper(session, name, tool_def) registry.register(wrapper) - logger.debug(f"MCP: registered tool '{wrapper.name}' from server '{name}'") + logger.debug("MCP: registered tool '{}' from server '{}'", wrapper.name, name) - logger.info(f"MCP server '{name}': connected, {len(tools.tools)} tools registered") + logger.info("MCP server '{}': connected, {} tools registered", name, len(tools.tools)) except Exception as e: logger.error("MCP server '{}': failed to connect: {}", name, e) diff --git a/nanobot/channels/dingtalk.py b/nanobot/channels/dingtalk.py index 3ac233f..f6dca30 100644 --- a/nanobot/channels/dingtalk.py +++ b/nanobot/channels/dingtalk.py @@ -220,7 +220,7 @@ class DingTalkChannel(BaseChannel): if resp.status_code != 200: logger.error("DingTalk send failed: {}", resp.text) else: - logger.debug(f"DingTalk message sent to {msg.chat_id}") + logger.debug("DingTalk message sent to {}", msg.chat_id) except Exception as e: logger.error("Error sending DingTalk message: {}", e) diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index ee54eed..8baecbf 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -94,7 +94,7 @@ class DiscordChannel(BaseChannel): if response.status_code == 429: data = response.json() retry_after = float(data.get("retry_after", 1.0)) - logger.warning(f"Discord rate limited, retrying in {retry_after}s") + logger.warning("Discord rate limited, retrying in {}s", retry_after) await asyncio.sleep(retry_after) continue response.raise_for_status() diff --git a/nanobot/channels/email.py b/nanobot/channels/email.py index 8a1ee79..1b6f46b 100644 --- a/nanobot/channels/email.py +++ b/nanobot/channels/email.py @@ -162,7 +162,7 @@ class EmailChannel(BaseChannel): missing.append("smtp_password") if missing: - logger.error(f"Email channel not configured, missing: {', '.join(missing)}") + logger.error("Email channel not configured, missing: {}", ', '.join(missing)) return False return True diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 6f62202..c17bf1a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -196,7 +196,7 @@ class FeishuChannel(BaseChannel): if not response.success(): logger.warning("Failed to add reaction: code={}, msg={}", response.code, response.msg) else: - logger.debug(f"Added {emoji_type} reaction to message {message_id}") + logger.debug("Added {} reaction to message {}", emoji_type, message_id) except Exception as e: logger.warning("Error adding reaction: {}", e) @@ -309,7 +309,7 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.image.create(request) if response.success(): image_key = response.data.image_key - logger.debug(f"Uploaded image {os.path.basename(file_path)}: {image_key}") + logger.debug("Uploaded image {}: {}", os.path.basename(file_path), image_key) return image_key else: logger.error("Failed to upload image: code={}, msg={}", response.code, response.msg) @@ -336,7 +336,7 @@ class FeishuChannel(BaseChannel): response = self._client.im.v1.file.create(request) if response.success(): file_key = response.data.file_key - logger.debug(f"Uploaded file {file_name}: {file_key}") + logger.debug("Uploaded file {}: {}", file_name, file_key) return file_key else: logger.error("Failed to upload file: code={}, msg={}", response.code, response.msg) @@ -364,7 +364,7 @@ class FeishuChannel(BaseChannel): msg_type, response.code, response.msg, response.get_log_id() ) return False - logger.debug(f"Feishu {msg_type} message sent to {receive_id}") + logger.debug("Feishu {} message sent to {}", msg_type, receive_id) return True except Exception as e: logger.error("Error sending Feishu {} message: {}", msg_type, e) @@ -382,7 +382,7 @@ class FeishuChannel(BaseChannel): for file_path in msg.media: if not os.path.isfile(file_path): - logger.warning(f"Media file not found: {file_path}") + logger.warning("Media file not found: {}", file_path) continue ext = os.path.splitext(file_path)[1].lower() if ext in self._IMAGE_EXTS: diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 3e714c3..6fbab04 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -45,7 +45,7 @@ class ChannelManager: ) logger.info("Telegram channel enabled") except ImportError as e: - logger.warning(f"Telegram channel not available: {e}") + logger.warning("Telegram channel not available: {}", e) # WhatsApp channel if self.config.channels.whatsapp.enabled: @@ -56,7 +56,7 @@ class ChannelManager: ) logger.info("WhatsApp channel enabled") except ImportError as e: - logger.warning(f"WhatsApp channel not available: {e}") + logger.warning("WhatsApp channel not available: {}", e) # Discord channel if self.config.channels.discord.enabled: @@ -67,7 +67,7 @@ class ChannelManager: ) logger.info("Discord channel enabled") except ImportError as e: - logger.warning(f"Discord channel not available: {e}") + logger.warning("Discord channel not available: {}", e) # Feishu channel if self.config.channels.feishu.enabled: @@ -78,7 +78,7 @@ class ChannelManager: ) logger.info("Feishu channel enabled") except ImportError as e: - logger.warning(f"Feishu channel not available: {e}") + logger.warning("Feishu channel not available: {}", e) # Mochat channel if self.config.channels.mochat.enabled: @@ -90,7 +90,7 @@ class ChannelManager: ) logger.info("Mochat channel enabled") except ImportError as e: - logger.warning(f"Mochat channel not available: {e}") + logger.warning("Mochat channel not available: {}", e) # DingTalk channel if self.config.channels.dingtalk.enabled: @@ -101,7 +101,7 @@ class ChannelManager: ) logger.info("DingTalk channel enabled") except ImportError as e: - logger.warning(f"DingTalk channel not available: {e}") + logger.warning("DingTalk channel not available: {}", e) # Email channel if self.config.channels.email.enabled: @@ -112,7 +112,7 @@ class ChannelManager: ) logger.info("Email channel enabled") except ImportError as e: - logger.warning(f"Email channel not available: {e}") + logger.warning("Email channel not available: {}", e) # Slack channel if self.config.channels.slack.enabled: @@ -123,7 +123,7 @@ class ChannelManager: ) logger.info("Slack channel enabled") except ImportError as e: - logger.warning(f"Slack channel not available: {e}") + logger.warning("Slack channel not available: {}", e) # QQ channel if self.config.channels.qq.enabled: @@ -135,7 +135,7 @@ class ChannelManager: ) logger.info("QQ channel enabled") except ImportError as e: - logger.warning(f"QQ channel not available: {e}") + logger.warning("QQ channel not available: {}", e) async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" @@ -156,7 +156,7 @@ class ChannelManager: # Start channels tasks = [] for name, channel in self.channels.items(): - logger.info(f"Starting {name} channel...") + logger.info("Starting {} channel...", name) tasks.append(asyncio.create_task(self._start_channel(name, channel))) # Wait for all to complete (they should run forever) @@ -178,7 +178,7 @@ class ChannelManager: for name, channel in self.channels.items(): try: await channel.stop() - logger.info(f"Stopped {name} channel") + logger.info("Stopped {} channel", name) except Exception as e: logger.error("Error stopping {}: {}", name, e) @@ -200,7 +200,7 @@ class ChannelManager: except Exception as e: logger.error("Error sending to {}: {}", msg.channel, e) else: - logger.warning(f"Unknown channel: {msg.channel}") + logger.warning("Unknown channel: {}", msg.channel) except asyncio.TimeoutError: continue diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 1d00bc7..16cbfb8 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -34,7 +34,7 @@ def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]": super().__init__(intents=intents) async def on_ready(self): - logger.info(f"QQ bot ready: {self.robot.name}") + logger.info("QQ bot ready: {}", self.robot.name) async def on_c2c_message_create(self, message: "C2CMessage"): await channel._on_message(message) diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py index 7dd2971..79cbe76 100644 --- a/nanobot/channels/slack.py +++ b/nanobot/channels/slack.py @@ -36,7 +36,7 @@ class SlackChannel(BaseChannel): logger.error("Slack bot/app token not configured") return if self.config.mode != "socket": - logger.error(f"Unsupported Slack mode: {self.config.mode}") + logger.error("Unsupported Slack mode: {}", self.config.mode) return self._running = True @@ -53,7 +53,7 @@ class SlackChannel(BaseChannel): try: auth = await self._web_client.auth_test() self._bot_user_id = auth.get("user_id") - logger.info(f"Slack bot connected as {self._bot_user_id}") + logger.info("Slack bot connected as {}", self._bot_user_id) except Exception as e: logger.warning("Slack auth_test failed: {}", e) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 42db489..fa36c98 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -165,7 +165,7 @@ class TelegramChannel(BaseChannel): # Get bot info and register command menu bot_info = await self._app.bot.get_me() - logger.info(f"Telegram bot @{bot_info.username} connected") + logger.info("Telegram bot @{} connected", bot_info.username) try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) @@ -221,7 +221,7 @@ class TelegramChannel(BaseChannel): try: chat_id = int(msg.chat_id) except ValueError: - logger.error(f"Invalid chat_id: {msg.chat_id}") + logger.error("Invalid chat_id: {}", msg.chat_id) return # Send media files @@ -344,14 +344,14 @@ class TelegramChannel(BaseChannel): transcriber = GroqTranscriptionProvider(api_key=self.groq_api_key) transcription = await transcriber.transcribe(file_path) if transcription: - logger.info(f"Transcribed {media_type}: {transcription[:50]}...") + logger.info("Transcribed {}: {}...", media_type, transcription[:50]) content_parts.append(f"[transcription: {transcription}]") else: content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {file_path}]") - logger.debug(f"Downloaded {media_type} to {file_path}") + logger.debug("Downloaded {} to {}", media_type, file_path) except Exception as e: logger.error("Failed to download media: {}", e) content_parts.append(f"[{media_type}: download failed]") diff --git a/nanobot/channels/whatsapp.py b/nanobot/channels/whatsapp.py index 4d12360..f3e14d9 100644 --- a/nanobot/channels/whatsapp.py +++ b/nanobot/channels/whatsapp.py @@ -34,7 +34,7 @@ class WhatsAppChannel(BaseChannel): bridge_url = self.config.bridge_url - logger.info(f"Connecting to WhatsApp bridge at {bridge_url}...") + logger.info("Connecting to WhatsApp bridge at {}...", bridge_url) self._running = True @@ -112,11 +112,11 @@ class WhatsAppChannel(BaseChannel): # Extract just the phone number or lid as chat_id user_id = pn if pn else sender sender_id = user_id.split("@")[0] if "@" in user_id else user_id - logger.info(f"Sender {sender}") + logger.info("Sender {}", sender) # Handle voice transcription if it's a voice message if content == "[Voice Message]": - logger.info(f"Voice message received from {sender_id}, but direct download from bridge is not yet supported.") + 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]" await self._handle_message( @@ -133,7 +133,7 @@ class WhatsAppChannel(BaseChannel): elif msg_type == "status": # Connection status update status = data.get("status") - logger.info(f"WhatsApp status: {status}") + logger.info("WhatsApp status: {}", status) if status == "connected": self._connected = True diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index d2b9ef7..4c14ef7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -157,7 +157,7 @@ class CronService: self._recompute_next_runs() self._save_store() self._arm_timer() - logger.info(f"Cron service started with {len(self._store.jobs if self._store else [])} jobs") + logger.info("Cron service started with {} jobs", len(self._store.jobs if self._store else [])) def stop(self) -> None: """Stop the cron service.""" @@ -222,7 +222,7 @@ class CronService: async def _execute_job(self, job: CronJob) -> None: """Execute a single job.""" start_ms = _now_ms() - logger.info(f"Cron: executing job '{job.name}' ({job.id})") + logger.info("Cron: executing job '{}' ({})", job.name, job.id) try: response = None @@ -231,7 +231,7 @@ class CronService: job.state.last_status = "ok" job.state.last_error = None - logger.info(f"Cron: job '{job.name}' completed") + logger.info("Cron: job '{}' completed", job.name) except Exception as e: job.state.last_status = "error" @@ -296,7 +296,7 @@ class CronService: self._save_store() self._arm_timer() - logger.info(f"Cron: added job '{name}' ({job.id})") + logger.info("Cron: added job '{}' ({})", name, job.id) return job def remove_job(self, job_id: str) -> bool: @@ -309,7 +309,7 @@ class CronService: if removed: self._save_store() self._arm_timer() - logger.info(f"Cron: removed job {job_id}") + logger.info("Cron: removed job {}", job_id) return removed diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 8bdc78f..8b33e3a 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -78,7 +78,7 @@ class HeartbeatService: self._running = True self._task = asyncio.create_task(self._run_loop()) - logger.info(f"Heartbeat started (every {self.interval_s}s)") + logger.info("Heartbeat started (every {}s)", self.interval_s) def stop(self) -> None: """Stop the heartbeat service.""" @@ -118,7 +118,7 @@ class HeartbeatService: if HEARTBEAT_OK_TOKEN.replace("_", "") in response.upper().replace("_", ""): logger.info("Heartbeat: OK (no action needed)") else: - logger.info(f"Heartbeat: completed task") + logger.info("Heartbeat: completed task") except Exception as e: logger.error("Heartbeat execution failed: {}", e) diff --git a/nanobot/providers/transcription.py b/nanobot/providers/transcription.py index eb5969d..7a3c628 100644 --- a/nanobot/providers/transcription.py +++ b/nanobot/providers/transcription.py @@ -35,7 +35,7 @@ class GroqTranscriptionProvider: path = Path(file_path) if not path.exists(): - logger.error(f"Audio file not found: {file_path}") + logger.error("Audio file not found: {}", file_path) return "" try: diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 44dcecb..9c0c7de 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -110,7 +110,7 @@ class SessionManager: if legacy_path.exists(): import shutil shutil.move(str(legacy_path), str(path)) - logger.info(f"Migrated session {key} from legacy path") + logger.info("Migrated session {} from legacy path", key) if not path.exists(): return None