From fafd8d4eb86c856c72d3dcabab59a013ed5a741a Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 26 Feb 2026 00:23:58 +0800 Subject: [PATCH 01/13] fix(agent): only suppress final reply when message tool sends to same target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A refactoring in commit 132807a introduced a regression where the final response was silently discarded whenever the message tool was used, regardless of the target. This restored the original logic from PR #832 that only suppresses the final reply when the message tool sends to the same (channel, chat_id) as the original message. Changes: - message.py: Replace _sent_in_turn: bool with _turn_sends: list[tuple] to track actual send targets, add get_turn_sends() method - loop.py: Check if (msg.channel, msg.chat_id) is in sent_targets before suppressing final reply. Also move the "Response to" log after the suppress check to avoid misleading logs. - Add unit tests for the suppress logic This ensures: - Email sent via message tool → Feishu still gets confirmation - Message tool sends to same Feishu chat → No duplicate (suppressed) --- nanobot/agent/loop.py | 19 ++- nanobot/agent/tools/message.py | 10 +- tests/test_message_tool_suppress.py | 200 ++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 8 deletions(-) create mode 100644 tests/test_message_tool_suppress.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 8be8e51..2a998d4 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -407,16 +407,25 @@ class AgentLoop: if final_content is None: 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("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) - self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) + suppress_final_reply = False if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn: - return None + if isinstance(message_tool, MessageTool): + sent_targets = set(message_tool.get_turn_sends()) + suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets + if suppress_final_reply: + logger.info( + "Skipping final auto-reply because message tool already sent to {}:{} in this turn", + msg.channel, + msg.chat_id, + ) + return None + + preview = final_content[:120] + "..." if len(final_content) > 120 else final_content + logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) return OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=final_content, metadata=msg.metadata or {}, diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index 40e76e3..be359f3 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -20,7 +20,7 @@ class MessageTool(Tool): self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - self._sent_in_turn: bool = False + self._turn_sends: list[tuple[str, str]] = [] def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" @@ -34,7 +34,11 @@ class MessageTool(Tool): def start_turn(self) -> None: """Reset per-turn send tracking.""" - self._sent_in_turn = False + self._turn_sends.clear() + + def get_turn_sends(self) -> list[tuple[str, str]]: + """Get (channel, chat_id) targets sent in the current turn.""" + return list(self._turn_sends) @property def name(self) -> str: @@ -101,7 +105,7 @@ class MessageTool(Tool): try: await self._send_callback(msg) - self._sent_in_turn = True + self._turn_sends.append((channel, chat_id)) media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py new file mode 100644 index 0000000..77436a0 --- /dev/null +++ b/tests/test_message_tool_suppress.py @@ -0,0 +1,200 @@ +"""Test message tool suppress logic for final replies.""" + +import asyncio +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from nanobot.agent.loop import AgentLoop +from nanobot.agent.tools.message import MessageTool +from nanobot.bus.events import InboundMessage, OutboundMessage +from nanobot.bus.queue import MessageBus +from nanobot.providers.base import LLMResponse, ToolCallRequest + + +class TestMessageToolSuppressLogic: + """Test that final reply is only suppressed when message tool sends to same target.""" + + @pytest.mark.asyncio + async def test_final_reply_suppressed_when_message_tool_sends_to_same_target( + self, tmp_path: Path + ) -> None: + """If message tool sends to the same (channel, chat_id), final reply is suppressed.""" + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + # First call returns tool call, second call returns final response + tool_call = ToolCallRequest( + id="call1", + name="message", + arguments={"content": "Hello from tool", "channel": "feishu", "chat_id": "chat123"} + ) + + call_count = 0 + + def mock_chat(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return LLMResponse(content="", tool_calls=[tool_call]) + else: + return LLMResponse(content="Done", tool_calls=[]) + + loop.provider.chat = AsyncMock(side_effect=mock_chat) + loop.tools.get_definitions = MagicMock(return_value=[]) + + # Track outbound messages + sent_messages: list[OutboundMessage] = [] + + async def _capture_outbound(msg: OutboundMessage) -> None: + sent_messages.append(msg) + + # Set up message tool with callback + message_tool = loop.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.set_send_callback(_capture_outbound) + + msg = InboundMessage( + channel="feishu", sender_id="user1", chat_id="chat123", content="Send a message" + ) + result = await loop._process_message(msg) + + # Message tool should have sent to the same target + assert len(sent_messages) == 1 + assert sent_messages[0].channel == "feishu" + assert sent_messages[0].chat_id == "chat123" + + # Final reply should be None (suppressed) + assert result is None + + @pytest.mark.asyncio + async def test_final_reply_sent_when_message_tool_sends_to_different_target( + self, tmp_path: Path + ) -> None: + """If message tool sends to a different target, final reply is still sent.""" + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + # First call returns tool call to email, second call returns final response + tool_call = ToolCallRequest( + id="call1", + name="message", + arguments={"content": "Email content", "channel": "email", "chat_id": "user@example.com"} + ) + + call_count = 0 + + def mock_chat(*args, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + return LLMResponse(content="", tool_calls=[tool_call]) + else: + return LLMResponse(content="I've sent the email.", tool_calls=[]) + + loop.provider.chat = AsyncMock(side_effect=mock_chat) + loop.tools.get_definitions = MagicMock(return_value=[]) + + # Track outbound messages + sent_messages: list[OutboundMessage] = [] + + async def _capture_outbound(msg: OutboundMessage) -> None: + sent_messages.append(msg) + + # Set up message tool with callback + message_tool = loop.tools.get("message") + if isinstance(message_tool, MessageTool): + message_tool.set_send_callback(_capture_outbound) + + msg = InboundMessage( + channel="feishu", sender_id="user1", chat_id="chat123", content="Send an email" + ) + result = await loop._process_message(msg) + + # Message tool should have sent to email + assert len(sent_messages) == 1 + assert sent_messages[0].channel == "email" + assert sent_messages[0].chat_id == "user@example.com" + + # Final reply should be sent to Feishu (not suppressed) + assert result is not None + assert result.channel == "feishu" + assert result.chat_id == "chat123" + assert "email" in result.content.lower() or "sent" in result.content.lower() + + @pytest.mark.asyncio + async def test_final_reply_sent_when_no_message_tool_used(self, tmp_path: Path) -> None: + """If no message tool is used, final reply is always sent.""" + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + loop = AgentLoop( + bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 + ) + + # Mock provider to return a simple response without tool calls + loop.provider.chat = AsyncMock(return_value=LLMResponse( + content="Hello! How can I help you?", + tool_calls=[] + )) + loop.tools.get_definitions = MagicMock(return_value=[]) + + msg = InboundMessage( + channel="feishu", sender_id="user1", chat_id="chat123", content="Hi" + ) + result = await loop._process_message(msg) + + # Final reply should be sent + assert result is not None + assert result.channel == "feishu" + assert result.chat_id == "chat123" + assert "Hello" in result.content + + +class TestMessageToolTurnTracking: + """Test MessageTool's turn tracking functionality.""" + + def test_turn_sends_tracking(self) -> None: + """MessageTool correctly tracks sends per turn.""" + tool = MessageTool() + + # Initially empty + assert tool.get_turn_sends() == [] + + # Simulate sends + tool._turn_sends.append(("feishu", "chat1")) + tool._turn_sends.append(("email", "user@example.com")) + + sends = tool.get_turn_sends() + assert len(sends) == 2 + assert ("feishu", "chat1") in sends + assert ("email", "user@example.com") in sends + + def test_start_turn_clears_tracking(self) -> None: + """start_turn() clears the turn sends list.""" + tool = MessageTool() + tool._turn_sends.append(("feishu", "chat1")) + assert len(tool.get_turn_sends()) == 1 + + tool.start_turn() + assert tool.get_turn_sends() == [] + + def test_get_turn_sends_returns_copy(self) -> None: + """get_turn_sends() returns a copy, not the original list.""" + tool = MessageTool() + tool._turn_sends.append(("feishu", "chat1")) + + sends = tool.get_turn_sends() + sends.append(("email", "user@example.com")) # Modify the copy + + # Original should be unchanged + assert len(tool.get_turn_sends()) == 1 From 7a3788fee93b581a1b2872ad5836b7d7348dbc63 Mon Sep 17 00:00:00 2001 From: Yongfeng Huang <1040488613@qq.com> Date: Thu, 26 Feb 2026 15:43:04 +0800 Subject: [PATCH 02/13] fix(web): use self.api_key instead of undefined api_key Made-with: Cursor --- nanobot/agent/tools/web.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nanobot/agent/tools/web.py b/nanobot/agent/tools/web.py index 56956c3..7860f12 100644 --- a/nanobot/agent/tools/web.py +++ b/nanobot/agent/tools/web.py @@ -80,7 +80,7 @@ class WebSearchTool(Tool): r = await client.get( "https://api.search.brave.com/res/v1/web/search", params={"q": query, "count": n}, - headers={"Accept": "application/json", "X-Subscription-Token": api_key}, + headers={"Accept": "application/json", "X-Subscription-Token": self.api_key}, timeout=10.0 ) r.raise_for_status() From 29e6709e261632b0494760f053711bf677ab6b22 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 02:27:18 +0000 Subject: [PATCH 03/13] =?UTF-8?q?refactor:=20simplify=20message=20tool=20s?= =?UTF-8?q?uppress=20=E2=80=94=20bool=20check=20instead=20of=20target=20tr?= =?UTF-8?q?acking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nanobot/agent/loop.py | 13 +- nanobot/agent/tools/message.py | 11 +- tests/test_message_tool_suppress.py | 203 ++++++++-------------------- 3 files changed, 58 insertions(+), 169 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index c6e565b..6155f99 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -444,18 +444,7 @@ class AgentLoop: self._save_turn(session, all_msgs, 1 + len(history)) self.sessions.save(session) - suppress_final_reply = False - if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool): - sent_targets = set(message_tool.get_turn_sends()) - suppress_final_reply = (msg.channel, msg.chat_id) in sent_targets - - if suppress_final_reply: - logger.info( - "Skipping final auto-reply because message tool already sent to {}:{} in this turn", - msg.channel, - msg.chat_id, - ) + if (mt := self.tools.get("message")) and isinstance(mt, MessageTool) and mt._sent_in_turn: return None preview = final_content[:120] + "..." if len(final_content) > 120 else final_content diff --git a/nanobot/agent/tools/message.py b/nanobot/agent/tools/message.py index be359f3..35e519a 100644 --- a/nanobot/agent/tools/message.py +++ b/nanobot/agent/tools/message.py @@ -20,7 +20,7 @@ class MessageTool(Tool): self._default_channel = default_channel self._default_chat_id = default_chat_id self._default_message_id = default_message_id - self._turn_sends: list[tuple[str, str]] = [] + self._sent_in_turn: bool = False def set_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Set the current message context.""" @@ -34,11 +34,7 @@ class MessageTool(Tool): def start_turn(self) -> None: """Reset per-turn send tracking.""" - self._turn_sends.clear() - - def get_turn_sends(self) -> list[tuple[str, str]]: - """Get (channel, chat_id) targets sent in the current turn.""" - return list(self._turn_sends) + self._sent_in_turn = False @property def name(self) -> str: @@ -105,7 +101,8 @@ class MessageTool(Tool): try: await self._send_callback(msg) - self._turn_sends.append((channel, chat_id)) + if channel == self._default_channel and chat_id == self._default_chat_id: + self._sent_in_turn = True media_info = f" with {len(media)} attachments" if media else "" return f"Message sent to {channel}:{chat_id}{media_info}" except Exception as e: diff --git a/tests/test_message_tool_suppress.py b/tests/test_message_tool_suppress.py index 77436a0..26b8a16 100644 --- a/tests/test_message_tool_suppress.py +++ b/tests/test_message_tool_suppress.py @@ -1,6 +1,5 @@ """Test message tool suppress logic for final replies.""" -import asyncio from pathlib import Path from unittest.mock import AsyncMock, MagicMock @@ -13,188 +12,92 @@ from nanobot.bus.queue import MessageBus from nanobot.providers.base import LLMResponse, ToolCallRequest +def _make_loop(tmp_path: Path) -> AgentLoop: + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10) + + class TestMessageToolSuppressLogic: - """Test that final reply is only suppressed when message tool sends to same target.""" + """Final reply suppressed only when message tool sends to the same target.""" @pytest.mark.asyncio - async def test_final_reply_suppressed_when_message_tool_sends_to_same_target( - self, tmp_path: Path - ) -> None: - """If message tool sends to the same (channel, chat_id), final reply is suppressed.""" - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - loop = AgentLoop( - bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 - ) - - # First call returns tool call, second call returns final response + async def test_suppress_when_sent_to_same_target(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) tool_call = ToolCallRequest( - id="call1", - name="message", - arguments={"content": "Hello from tool", "channel": "feishu", "chat_id": "chat123"} + id="call1", name="message", + arguments={"content": "Hello", "channel": "feishu", "chat_id": "chat123"}, ) - - call_count = 0 - - def mock_chat(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - return LLMResponse(content="", tool_calls=[tool_call]) - else: - return LLMResponse(content="Done", tool_calls=[]) - - loop.provider.chat = AsyncMock(side_effect=mock_chat) + calls = iter([ + LLMResponse(content="", tool_calls=[tool_call]), + LLMResponse(content="Done", tool_calls=[]), + ]) + loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.tools.get_definitions = MagicMock(return_value=[]) - # Track outbound messages - sent_messages: list[OutboundMessage] = [] + sent: list[OutboundMessage] = [] + mt = loop.tools.get("message") + if isinstance(mt, MessageTool): + mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m))) - async def _capture_outbound(msg: OutboundMessage) -> None: - sent_messages.append(msg) - - # Set up message tool with callback - message_tool = loop.tools.get("message") - if isinstance(message_tool, MessageTool): - message_tool.set_send_callback(_capture_outbound) - - msg = InboundMessage( - channel="feishu", sender_id="user1", chat_id="chat123", content="Send a message" - ) + msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Send") result = await loop._process_message(msg) - # Message tool should have sent to the same target - assert len(sent_messages) == 1 - assert sent_messages[0].channel == "feishu" - assert sent_messages[0].chat_id == "chat123" - - # Final reply should be None (suppressed) - assert result is None + assert len(sent) == 1 + assert result is None # suppressed @pytest.mark.asyncio - async def test_final_reply_sent_when_message_tool_sends_to_different_target( - self, tmp_path: Path - ) -> None: - """If message tool sends to a different target, final reply is still sent.""" - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - loop = AgentLoop( - bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 - ) - - # First call returns tool call to email, second call returns final response + async def test_not_suppress_when_sent_to_different_target(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) tool_call = ToolCallRequest( - id="call1", - name="message", - arguments={"content": "Email content", "channel": "email", "chat_id": "user@example.com"} + id="call1", name="message", + arguments={"content": "Email content", "channel": "email", "chat_id": "user@example.com"}, ) - - call_count = 0 - - def mock_chat(*args, **kwargs): - nonlocal call_count - call_count += 1 - if call_count == 1: - return LLMResponse(content="", tool_calls=[tool_call]) - else: - return LLMResponse(content="I've sent the email.", tool_calls=[]) - - loop.provider.chat = AsyncMock(side_effect=mock_chat) + calls = iter([ + LLMResponse(content="", tool_calls=[tool_call]), + LLMResponse(content="I've sent the email.", tool_calls=[]), + ]) + loop.provider.chat = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.tools.get_definitions = MagicMock(return_value=[]) - # Track outbound messages - sent_messages: list[OutboundMessage] = [] + sent: list[OutboundMessage] = [] + mt = loop.tools.get("message") + if isinstance(mt, MessageTool): + mt.set_send_callback(AsyncMock(side_effect=lambda m: sent.append(m))) - async def _capture_outbound(msg: OutboundMessage) -> None: - sent_messages.append(msg) - - # Set up message tool with callback - message_tool = loop.tools.get("message") - if isinstance(message_tool, MessageTool): - message_tool.set_send_callback(_capture_outbound) - - msg = InboundMessage( - channel="feishu", sender_id="user1", chat_id="chat123", content="Send an email" - ) + msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Send email") result = await loop._process_message(msg) - # Message tool should have sent to email - assert len(sent_messages) == 1 - assert sent_messages[0].channel == "email" - assert sent_messages[0].chat_id == "user@example.com" - - # Final reply should be sent to Feishu (not suppressed) - assert result is not None + assert len(sent) == 1 + assert sent[0].channel == "email" + assert result is not None # not suppressed assert result.channel == "feishu" - assert result.chat_id == "chat123" - assert "email" in result.content.lower() or "sent" in result.content.lower() @pytest.mark.asyncio - async def test_final_reply_sent_when_no_message_tool_used(self, tmp_path: Path) -> None: - """If no message tool is used, final reply is always sent.""" - bus = MessageBus() - provider = MagicMock() - provider.get_default_model.return_value = "test-model" - loop = AgentLoop( - bus=bus, provider=provider, workspace=tmp_path, model="test-model", memory_window=10 - ) - - # Mock provider to return a simple response without tool calls - loop.provider.chat = AsyncMock(return_value=LLMResponse( - content="Hello! How can I help you?", - tool_calls=[] - )) + async def test_not_suppress_when_no_message_tool_used(self, tmp_path: Path) -> None: + loop = _make_loop(tmp_path) + loop.provider.chat = AsyncMock(return_value=LLMResponse(content="Hello!", tool_calls=[])) loop.tools.get_definitions = MagicMock(return_value=[]) - msg = InboundMessage( - channel="feishu", sender_id="user1", chat_id="chat123", content="Hi" - ) + msg = InboundMessage(channel="feishu", sender_id="user1", chat_id="chat123", content="Hi") result = await loop._process_message(msg) - # Final reply should be sent assert result is not None - assert result.channel == "feishu" - assert result.chat_id == "chat123" assert "Hello" in result.content class TestMessageToolTurnTracking: - """Test MessageTool's turn tracking functionality.""" - def test_turn_sends_tracking(self) -> None: - """MessageTool correctly tracks sends per turn.""" + def test_sent_in_turn_tracks_same_target(self) -> None: tool = MessageTool() + tool.set_context("feishu", "chat1") + assert not tool._sent_in_turn + tool._sent_in_turn = True + assert tool._sent_in_turn - # Initially empty - assert tool.get_turn_sends() == [] - - # Simulate sends - tool._turn_sends.append(("feishu", "chat1")) - tool._turn_sends.append(("email", "user@example.com")) - - sends = tool.get_turn_sends() - assert len(sends) == 2 - assert ("feishu", "chat1") in sends - assert ("email", "user@example.com") in sends - - def test_start_turn_clears_tracking(self) -> None: - """start_turn() clears the turn sends list.""" + def test_start_turn_resets(self) -> None: tool = MessageTool() - tool._turn_sends.append(("feishu", "chat1")) - assert len(tool.get_turn_sends()) == 1 - + tool._sent_in_turn = True tool.start_turn() - assert tool.get_turn_sends() == [] - - def test_get_turn_sends_returns_copy(self) -> None: - """get_turn_sends() returns a copy, not the original list.""" - tool = MessageTool() - tool._turn_sends.append(("feishu", "chat1")) - - sends = tool.get_turn_sends() - sends.append(("email", "user@example.com")) # Modify the copy - - # Original should be unchanged - assert len(tool.get_turn_sends()) == 1 + assert not tool._sent_in_turn From cb999ae82600915015a7cbe2c858d7f9b9a6cc0f Mon Sep 17 00:00:00 2001 From: Hon Jia Xuan Date: Fri, 27 Feb 2026 10:39:05 +0800 Subject: [PATCH 04/13] feat: implement automatic workspace template synchronization --- nanobot/cli/commands.py | 39 +++++------------------- nanobot/utils/helpers.py | 65 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 1c20b50..9dee105 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -186,7 +186,8 @@ def onboard(): console.print(f"[green]✓[/green] Created workspace at {workspace}") # Create default bootstrap files - _create_workspace_templates(workspace) + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace) console.print(f"\n{__logo__} nanobot is ready!") console.print("\nNext steps:") @@ -198,36 +199,6 @@ def onboard(): -def _create_workspace_templates(workspace: Path): - """Create default workspace template files from bundled templates.""" - from importlib.resources import files as pkg_files - - templates_dir = pkg_files("nanobot") / "templates" - - for item in templates_dir.iterdir(): - if not item.name.endswith(".md"): - continue - dest = workspace / item.name - if not dest.exists(): - dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") - console.print(f" [dim]Created {item.name}[/dim]") - - memory_dir = workspace / "memory" - memory_dir.mkdir(exist_ok=True) - - memory_template = templates_dir / "memory" / "MEMORY.md" - memory_file = memory_dir / "MEMORY.md" - if not memory_file.exists(): - memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8") - console.print(" [dim]Created memory/MEMORY.md[/dim]") - - history_file = memory_dir / "HISTORY.md" - if not history_file.exists(): - history_file.write_text("", encoding="utf-8") - console.print(" [dim]Created memory/HISTORY.md[/dim]") - - (workspace / "skills").mkdir(exist_ok=True) - def _make_provider(config: Config): """Create the appropriate LLM provider from config.""" @@ -294,6 +265,8 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) session_manager = SessionManager(config.workspace_path) @@ -447,6 +420,8 @@ def agent( from loguru import logger config = load_config() + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) @@ -1008,6 +983,8 @@ def status(): config_path = get_config_path() config = load_config() workspace = config.workspace_path + from nanobot.utils.helpers import sync_workspace_templates + sync_workspace_templates(workspace) console.print(f"{__logo__} nanobot Status\n") diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 06d8fd5..83653ac 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -76,4 +76,67 @@ def parse_session_key(key: str) -> tuple[str, str]: parts = key.split(":", 1) if len(parts) != 2: raise ValueError(f"Invalid session key: {key}") - return parts[0], parts[1] \ No newline at end of file + return parts[0], parts[1] + +def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: + """ + Synchronize default workspace template files from bundled templates. + Only creates files that do not exist. Returns list of added file names. + """ + from importlib.resources import files as pkg_files + from rich.console import Console + console = Console() + added = [] + + try: + templates_dir = pkg_files("nanobot") / "templates" + except Exception: + # Fallback for some environments where pkg_files might fail + return [] + + if not templates_dir.is_dir(): + return [] + + # 1. Sync root templates + for item in templates_dir.iterdir(): + if not item.name.endswith(".md"): + continue + dest = workspace / item.name + if not dest.exists(): + try: + dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") + added.append(item.name) + except Exception: + pass + + # 2. Sync memory templates + memory_dir = workspace / "memory" + memory_dir.mkdir(exist_ok=True) + + memory_src = templates_dir / "memory" / "MEMORY.md" + memory_dest = memory_dir / "MEMORY.md" + if memory_src.is_file() and not memory_dest.exists(): + try: + memory_dest.write_text(memory_src.read_text(encoding="utf-8"), encoding="utf-8") + added.append("memory/MEMORY.md") + except Exception: + pass + + # 3. History file (always ensure it exists) + history_file = memory_dir / "HISTORY.md" + if not history_file.exists(): + try: + history_file.write_text("", encoding="utf-8") + added.append("memory/HISTORY.md") + except Exception: + pass + + # 4. Ensure skills dir exists + (workspace / "skills").mkdir(exist_ok=True) + + # Print notices if files were added + if added and not silent: + for name in added: + console.print(f" [dim]Created {name}[/dim]") + + return added \ No newline at end of file From ec8dee802c3727e6293e1d0bba9c6d0bb171b718 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 02:39:38 +0000 Subject: [PATCH 05/13] refactor: simplify message tool suppress and inline consolidation locks --- README.md | 2 +- nanobot/agent/loop.py | 41 ++++++++++---------------------- tests/test_consolidate_offset.py | 2 +- 3 files changed, 14 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index be360dc..71922fb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,966 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,932 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 6155f99..e3a9d67 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -43,6 +43,8 @@ class AgentLoop: 5. Sends responses back """ + _TOOL_RESULT_MAX_CHARS = 500 + def __init__( self, bus: MessageBus, @@ -145,17 +147,10 @@ class AgentLoop: def _set_tool_context(self, channel: str, chat_id: str, message_id: str | None = None) -> None: """Update context for all tools that need routing info.""" - if message_tool := self.tools.get("message"): - if isinstance(message_tool, MessageTool): - message_tool.set_context(channel, chat_id, message_id) - - if spawn_tool := self.tools.get("spawn"): - if isinstance(spawn_tool, SpawnTool): - spawn_tool.set_context(channel, chat_id) - - if cron_tool := self.tools.get("cron"): - if isinstance(cron_tool, CronTool): - cron_tool.set_context(channel, chat_id) + for name in ("message", "spawn", "cron"): + if tool := self.tools.get(name): + if hasattr(tool, "set_context"): + tool.set_context(channel, chat_id, *([message_id] if name == "message" else [])) @staticmethod def _strip_think(text: str | None) -> str | None: @@ -315,18 +310,6 @@ class AgentLoop: self._running = False logger.info("Agent loop stopping") - def _get_consolidation_lock(self, session_key: str) -> asyncio.Lock: - lock = self._consolidation_locks.get(session_key) - if lock is None: - lock = asyncio.Lock() - self._consolidation_locks[session_key] = lock - return lock - - def _prune_consolidation_lock(self, session_key: str, lock: asyncio.Lock) -> None: - """Drop lock entry if no longer in use.""" - if not lock.locked(): - self._consolidation_locks.pop(session_key, None) - async def _process_message( self, msg: InboundMessage, @@ -362,7 +345,7 @@ class AgentLoop: # Slash commands cmd = msg.content.strip().lower() if cmd == "/new": - lock = self._get_consolidation_lock(session.key) + lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock()) self._consolidating.add(session.key) try: async with lock: @@ -383,7 +366,8 @@ class AgentLoop: ) finally: self._consolidating.discard(session.key) - self._prune_consolidation_lock(session.key, lock) + if not lock.locked(): + self._consolidation_locks.pop(session.key, None) session.clear() self.sessions.save(session) @@ -397,7 +381,7 @@ class AgentLoop: unconsolidated = len(session.messages) - session.last_consolidated if (unconsolidated >= self.memory_window and session.key not in self._consolidating): self._consolidating.add(session.key) - lock = self._get_consolidation_lock(session.key) + lock = self._consolidation_locks.setdefault(session.key, asyncio.Lock()) async def _consolidate_and_unlock(): try: @@ -405,7 +389,8 @@ class AgentLoop: await self._consolidate_memory(session) finally: self._consolidating.discard(session.key) - self._prune_consolidation_lock(session.key, lock) + if not lock.locked(): + self._consolidation_locks.pop(session.key, None) _task = asyncio.current_task() if _task is not None: self._consolidation_tasks.discard(_task) @@ -454,8 +439,6 @@ class AgentLoop: metadata=msg.metadata or {}, ) - _TOOL_RESULT_MAX_CHARS = 500 - def _save_turn(self, session: Session, messages: list[dict], skip: int) -> None: """Save new-turn messages into session, truncating large tool results.""" from datetime import datetime diff --git a/tests/test_consolidate_offset.py b/tests/test_consolidate_offset.py index 323519e..6755124 100644 --- a/tests/test_consolidate_offset.py +++ b/tests/test_consolidate_offset.py @@ -812,7 +812,7 @@ class TestConsolidationDeduplicationGuard: loop.sessions.save(session) # Ensure lock exists before /new. - _ = loop._get_consolidation_lock(session.key) + loop._consolidation_locks.setdefault(session.key, asyncio.Lock()) assert session.key in loop._consolidation_locks async def _ok_consolidate(sess, archive_all: bool = False) -> bool: From 6641bad337668d23344b21a724e3e5f61e561158 Mon Sep 17 00:00:00 2001 From: kimkitsuragi26 Date: Fri, 27 Feb 2026 11:45:44 +0800 Subject: [PATCH 06/13] feat(feishu): make reaction emoji configurable Replace hardcoded THUMBSUP with configurable react_emoji field in FeishuConfig, consistent with SlackConfig.react_emoji pattern. Default remains THUMBSUP for backward compatibility. --- nanobot/channels/feishu.py | 2 +- nanobot/config/schema.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 480bf7b..4a6312e 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -692,7 +692,7 @@ class FeishuChannel(BaseChannel): msg_type = message.message_type # Add reaction - await self._add_reaction(message_id, "THUMBSUP") + await self._add_reaction(message_id, self.config.react_emoji) # Parse content content_parts = [] diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61aee96..d83967c 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -42,6 +42,7 @@ 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) class DingTalkConfig(Base): From aa774733ea2a78798ed582c7bc1f72bb59af5487 Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:08:48 +0800 Subject: [PATCH 07/13] fix(telegram): aggregate media-group images into a single inbound turn --- nanobot/channels/telegram.py | 218 ++++++++++++++++++++++++----------- 1 file changed, 152 insertions(+), 66 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index 808f50c..bf2da73 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio import re + from loguru import logger -from telegram import BotCommand, Update, ReplyParameters -from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes +from telegram import BotCommand, ReplyParameters, Update +from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage @@ -21,60 +22,60 @@ def _markdown_to_telegram_html(text: str) -> str: """ if not text: return "" - + # 1. Extract and protect code blocks (preserve content from other processing) code_blocks: list[str] = [] def save_code_block(m: re.Match) -> str: code_blocks.append(m.group(1)) return f"\x00CB{len(code_blocks) - 1}\x00" - + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) - + # 2. Extract and protect inline code inline_codes: list[str] = [] def save_inline_code(m: re.Match) -> str: inline_codes.append(m.group(1)) return f"\x00IC{len(inline_codes) - 1}\x00" - + text = re.sub(r'`([^`]+)`', save_inline_code, text) - + # 3. Headers # Title -> just the title text text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - + # 4. Blockquotes > text -> just the text (before HTML escaping) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) - + # 5. Escape HTML special characters text = text.replace("&", "&").replace("<", "<").replace(">", ">") - + # 6. Links [text](url) - must be before bold/italic to handle nested cases text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) - + # 7. Bold **text** or __text__ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - + # 8. Italic _text_ (avoid matching inside words like some_var_name) text = re.sub(r'(?\1', text) - + # 9. Strikethrough ~~text~~ text = re.sub(r'~~(.+?)~~', r'\1', text) - + # 10. Bullet lists - item -> • item text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) - + # 11. Restore inline code with HTML tags for i, code in enumerate(inline_codes): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00IC{i}\x00", f"{escaped}") - + # 12. Restore code blocks with HTML tags for i, code in enumerate(code_blocks): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") - + return text @@ -101,12 +102,12 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: class TelegramChannel(BaseChannel): """ Telegram channel using long polling. - + Simple and reliable - no webhook/public IP needed. """ - + name = "telegram" - + # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), @@ -114,7 +115,7 @@ class TelegramChannel(BaseChannel): BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] - + def __init__( self, config: TelegramConfig, @@ -127,15 +128,17 @@ class TelegramChannel(BaseChannel): self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task - + self._media_group_buffers: dict[str, dict[str, object]] = {} + self._media_group_tasks: dict[str, asyncio.Task] = {} + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: logger.error("Telegram bot token not configured") return - + 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) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) @@ -143,62 +146,69 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) - + # 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("help", self._on_help)) - + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) - & ~filters.COMMAND, + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, self._on_message ) ) - + logger.info("Starting Telegram bot (polling mode)...") - + # Initialize and start polling await self._app.initialize() await self._app.start() - + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info("Telegram bot @{} connected", bot_info.username) - + try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) - + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], drop_pending_updates=True # Ignore old messages on startup ) - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Telegram bot.""" self._running = False - + # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) - + + # Cancel buffered media-group flush tasks + for key, task in list(self._media_group_tasks.items()): + if task and not task.done(): + task.cancel() + self._media_group_tasks.pop(key, None) + self._media_group_buffers.clear() + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() self._app = None - + @staticmethod def _get_media_type(path: str) -> str: """Guess media type from file extension.""" @@ -246,7 +256,7 @@ class TelegramChannel(BaseChannel): param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: await sender( - chat_id=chat_id, + chat_id=chat_id, **{param: f}, reply_parameters=reply_params ) @@ -265,8 +275,8 @@ class TelegramChannel(BaseChannel): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=chat_id, - text=html, + chat_id=chat_id, + text=html, parse_mode="HTML", reply_parameters=reply_params ) @@ -274,13 +284,13 @@ class TelegramChannel(BaseChannel): logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message( - chat_id=chat_id, + chat_id=chat_id, text=chunk, reply_parameters=reply_params ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) - + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: @@ -319,34 +329,34 @@ class TelegramChannel(BaseChannel): chat_id=str(update.message.chat_id), content=update.message.text, ) - + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: return - + message = update.message user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) - + # Store chat_id for replies self._chat_ids[sender_id] = chat_id - + # Build content from text and/or media content_parts = [] media_paths = [] - + # Text content if message.text: content_parts.append(message.text) if message.caption: content_parts.append(message.caption) - + # Handle media files media_file = None media_type = None - + if message.photo: media_file = message.photo[-1] # Largest photo media_type = "image" @@ -359,23 +369,23 @@ class TelegramChannel(BaseChannel): elif message.document: media_file = message.document media_type = "file" - + # Download media if present if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) - + # Save to workspace/media/ from pathlib import Path media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) - + media_paths.append(str(file_path)) - + # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider @@ -388,21 +398,60 @@ class TelegramChannel(BaseChannel): content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {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]") - + content = "\n".join(content_parts) if content_parts else "[empty message]" - + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) - + str_chat_id = str(chat_id) - + + # Telegram media groups arrive as multiple messages sharing media_group_id. + # Buffer briefly and forward as one aggregated turn. + media_group_id = getattr(message, "media_group_id", None) + if media_group_id: + group_key = f"{str_chat_id}:{media_group_id}" + buffer = self._media_group_buffers.get(group_key) + if not buffer: + buffer = { + "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", + "media_group_id": media_group_id, + }, + } + self._media_group_buffers[group_key] = buffer + self._start_typing(str_chat_id) + + if content and content != "[empty message]": + cast_contents = buffer["contents"] + if isinstance(cast_contents, list): + cast_contents.append(content) + cast_media = buffer["media"] + if isinstance(cast_media, list): + cast_media.extend(media_paths) + + # Start one delayed flush task per media group. + if group_key not in self._media_group_tasks: + self._media_group_tasks[group_key] = asyncio.create_task( + self._flush_media_group(group_key) + ) + return + # Start typing indicator before processing self._start_typing(str_chat_id) - + # Forward to the message bus await self._handle_message( sender_id=sender_id, @@ -417,19 +466,56 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private" } ) - + + async def _flush_media_group(self, group_key: str, delay_s: float = 0.6) -> None: + """Flush buffered Telegram media-group messages as one aggregated turn.""" + try: + await asyncio.sleep(delay_s) + buffer = self._media_group_buffers.pop(group_key, None) + if not buffer: + return + + sender_id = str(buffer.get("sender_id", "")) + chat_id = str(buffer.get("chat_id", "")) + contents = buffer.get("contents") + media = buffer.get("media") + metadata = buffer.get("metadata") + + content_parts = [c for c in (contents if isinstance(contents, list) else []) if isinstance(c, str) and c] + media_paths = [m for m in (media if isinstance(media, list) else []) if isinstance(m, str) and m] + + # De-duplicate while preserving order + seen = set() + unique_media: list[str] = [] + for m in media_paths: + if m in seen: + continue + seen.add(m) + unique_media.append(m) + + content = "\n".join(content_parts) if content_parts else "[empty message]" + await self._handle_message( + sender_id=sender_id, + chat_id=chat_id, + content=content, + media=unique_media, + metadata=metadata if isinstance(metadata, dict) else {}, + ) + finally: + self._media_group_tasks.pop(group_key, None) + def _start_typing(self, chat_id: str) -> None: """Start sending 'typing...' indicator for a chat.""" # Cancel any existing typing task for this chat self._stop_typing(chat_id) self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) - + def _stop_typing(self, chat_id: str) -> None: """Stop the typing indicator for a chat.""" task = self._typing_tasks.pop(chat_id, None) if task and not task.done(): task.cancel() - + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: @@ -440,7 +526,7 @@ class TelegramChannel(BaseChannel): pass except Exception as 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("Telegram error: {}", context.error) @@ -454,6 +540,6 @@ class TelegramChannel(BaseChannel): } if mime_type in ext_map: return ext_map[mime_type] - + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} return type_map.get(media_type, "") From a3e0543eae66e566b9d5cb1c0e398bfc33b6e7d9 Mon Sep 17 00:00:00 2001 From: Kim <150593189+KimGLee@users.noreply.github.com> Date: Fri, 27 Feb 2026 12:16:51 +0800 Subject: [PATCH 08/13] chore(telegram): keep media-group fix without unrelated formatting changes --- nanobot/channels/telegram.py | 133 +++++++++++++++++------------------ 1 file changed, 66 insertions(+), 67 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index bf2da73..ed77963 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -4,10 +4,9 @@ from __future__ import annotations import asyncio import re - from loguru import logger -from telegram import BotCommand, ReplyParameters, Update -from telegram.ext import Application, CommandHandler, ContextTypes, MessageHandler, filters +from telegram import BotCommand, Update, ReplyParameters +from telegram.ext import Application, CommandHandler, MessageHandler, filters, ContextTypes from telegram.request import HTTPXRequest from nanobot.bus.events import OutboundMessage @@ -22,60 +21,60 @@ def _markdown_to_telegram_html(text: str) -> str: """ if not text: return "" - + # 1. Extract and protect code blocks (preserve content from other processing) code_blocks: list[str] = [] def save_code_block(m: re.Match) -> str: code_blocks.append(m.group(1)) return f"\x00CB{len(code_blocks) - 1}\x00" - + text = re.sub(r'```[\w]*\n?([\s\S]*?)```', save_code_block, text) - + # 2. Extract and protect inline code inline_codes: list[str] = [] def save_inline_code(m: re.Match) -> str: inline_codes.append(m.group(1)) return f"\x00IC{len(inline_codes) - 1}\x00" - + text = re.sub(r'`([^`]+)`', save_inline_code, text) - + # 3. Headers # Title -> just the title text text = re.sub(r'^#{1,6}\s+(.+)$', r'\1', text, flags=re.MULTILINE) - + # 4. Blockquotes > text -> just the text (before HTML escaping) text = re.sub(r'^>\s*(.*)$', r'\1', text, flags=re.MULTILINE) - + # 5. Escape HTML special characters text = text.replace("&", "&").replace("<", "<").replace(">", ">") - + # 6. Links [text](url) - must be before bold/italic to handle nested cases text = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1', text) - + # 7. Bold **text** or __text__ text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) text = re.sub(r'__(.+?)__', r'\1', text) - + # 8. Italic _text_ (avoid matching inside words like some_var_name) text = re.sub(r'(?\1', text) - + # 9. Strikethrough ~~text~~ text = re.sub(r'~~(.+?)~~', r'\1', text) - + # 10. Bullet lists - item -> • item text = re.sub(r'^[-*]\s+', '• ', text, flags=re.MULTILINE) - + # 11. Restore inline code with HTML tags for i, code in enumerate(inline_codes): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00IC{i}\x00", f"{escaped}") - + # 12. Restore code blocks with HTML tags for i, code in enumerate(code_blocks): # Escape HTML in code content escaped = code.replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace(f"\x00CB{i}\x00", f"
{escaped}
") - + return text @@ -102,12 +101,12 @@ def _split_message(content: str, max_len: int = 4000) -> list[str]: class TelegramChannel(BaseChannel): """ Telegram channel using long polling. - + Simple and reliable - no webhook/public IP needed. """ - + name = "telegram" - + # Commands registered with Telegram's command menu BOT_COMMANDS = [ BotCommand("start", "Start the bot"), @@ -115,7 +114,7 @@ class TelegramChannel(BaseChannel): BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), ] - + def __init__( self, config: TelegramConfig, @@ -130,15 +129,15 @@ class TelegramChannel(BaseChannel): self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task self._media_group_buffers: dict[str, dict[str, object]] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} - + async def start(self) -> None: """Start the Telegram bot with long polling.""" if not self.config.token: logger.error("Telegram bot token not configured") return - + 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) builder = Application.builder().token(self.config.token).request(req).get_updates_request(req) @@ -146,51 +145,51 @@ class TelegramChannel(BaseChannel): builder = builder.proxy(self.config.proxy).get_updates_proxy(self.config.proxy) self._app = builder.build() self._app.add_error_handler(self._on_error) - + # 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("help", self._on_help)) - + # Add message handler for text, photos, voice, documents self._app.add_handler( MessageHandler( - (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) - & ~filters.COMMAND, + (filters.TEXT | filters.PHOTO | filters.VOICE | filters.AUDIO | filters.Document.ALL) + & ~filters.COMMAND, self._on_message ) ) - + logger.info("Starting Telegram bot (polling mode)...") - + # Initialize and start polling await self._app.initialize() await self._app.start() - + # Get bot info and register command menu bot_info = await self._app.bot.get_me() logger.info("Telegram bot @{} connected", bot_info.username) - + try: await self._app.bot.set_my_commands(self.BOT_COMMANDS) logger.debug("Telegram bot commands registered") except Exception as e: logger.warning("Failed to register bot commands: {}", e) - + # Start polling (this runs until stopped) await self._app.updater.start_polling( allowed_updates=["message"], drop_pending_updates=True # Ignore old messages on startup ) - + # Keep running until stopped while self._running: await asyncio.sleep(1) - + async def stop(self) -> None: """Stop the Telegram bot.""" self._running = False - + # Cancel all typing indicators for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) @@ -201,14 +200,14 @@ class TelegramChannel(BaseChannel): task.cancel() self._media_group_tasks.pop(key, None) self._media_group_buffers.clear() - + if self._app: logger.info("Stopping Telegram bot...") await self._app.updater.stop() await self._app.stop() await self._app.shutdown() self._app = None - + @staticmethod def _get_media_type(path: str) -> str: """Guess media type from file extension.""" @@ -256,7 +255,7 @@ class TelegramChannel(BaseChannel): param = "photo" if media_type == "photo" else media_type if media_type in ("voice", "audio") else "document" with open(media_path, 'rb') as f: await sender( - chat_id=chat_id, + chat_id=chat_id, **{param: f}, reply_parameters=reply_params ) @@ -275,8 +274,8 @@ class TelegramChannel(BaseChannel): try: html = _markdown_to_telegram_html(chunk) await self._app.bot.send_message( - chat_id=chat_id, - text=html, + chat_id=chat_id, + text=html, parse_mode="HTML", reply_parameters=reply_params ) @@ -284,13 +283,13 @@ class TelegramChannel(BaseChannel): logger.warning("HTML parse failed, falling back to plain text: {}", e) try: await self._app.bot.send_message( - chat_id=chat_id, + chat_id=chat_id, text=chunk, reply_parameters=reply_params ) except Exception as e2: logger.error("Error sending Telegram message: {}", e2) - + async def _on_start(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle /start command.""" if not update.message or not update.effective_user: @@ -329,34 +328,34 @@ class TelegramChannel(BaseChannel): chat_id=str(update.message.chat_id), content=update.message.text, ) - + async def _on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Handle incoming messages (text, photos, voice, documents).""" if not update.message or not update.effective_user: return - + message = update.message user = update.effective_user chat_id = message.chat_id sender_id = self._sender_id(user) - + # Store chat_id for replies self._chat_ids[sender_id] = chat_id - + # Build content from text and/or media content_parts = [] media_paths = [] - + # Text content if message.text: content_parts.append(message.text) if message.caption: content_parts.append(message.caption) - + # Handle media files media_file = None media_type = None - + if message.photo: media_file = message.photo[-1] # Largest photo media_type = "image" @@ -369,23 +368,23 @@ class TelegramChannel(BaseChannel): elif message.document: media_file = message.document media_type = "file" - + # Download media if present if media_file and self._app: try: file = await self._app.bot.get_file(media_file.file_id) ext = self._get_extension(media_type, getattr(media_file, 'mime_type', None)) - + # Save to workspace/media/ from pathlib import Path media_dir = Path.home() / ".nanobot" / "media" media_dir.mkdir(parents=True, exist_ok=True) - + file_path = media_dir / f"{media_file.file_id[:16]}{ext}" await file.download_to_drive(str(file_path)) - + media_paths.append(str(file_path)) - + # Handle voice transcription if media_type == "voice" or media_type == "audio": from nanobot.providers.transcription import GroqTranscriptionProvider @@ -398,16 +397,16 @@ class TelegramChannel(BaseChannel): content_parts.append(f"[{media_type}: {file_path}]") else: content_parts.append(f"[{media_type}: {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]") - + content = "\n".join(content_parts) if content_parts else "[empty message]" - + logger.debug("Telegram message from {}: {}...", sender_id, content[:50]) - + str_chat_id = str(chat_id) # Telegram media groups arrive as multiple messages sharing media_group_id. @@ -448,10 +447,10 @@ class TelegramChannel(BaseChannel): self._flush_media_group(group_key) ) return - + # Start typing indicator before processing self._start_typing(str_chat_id) - + # Forward to the message bus await self._handle_message( sender_id=sender_id, @@ -466,7 +465,7 @@ class TelegramChannel(BaseChannel): "is_group": message.chat.type != "private" } ) - + async def _flush_media_group(self, group_key: str, delay_s: float = 0.6) -> None: """Flush buffered Telegram media-group messages as one aggregated turn.""" try: @@ -509,13 +508,13 @@ class TelegramChannel(BaseChannel): # Cancel any existing typing task for this chat self._stop_typing(chat_id) self._typing_tasks[chat_id] = asyncio.create_task(self._typing_loop(chat_id)) - + def _stop_typing(self, chat_id: str) -> None: """Stop the typing indicator for a chat.""" task = self._typing_tasks.pop(chat_id, None) if task and not task.done(): task.cancel() - + async def _typing_loop(self, chat_id: str) -> None: """Repeatedly send 'typing' action until cancelled.""" try: @@ -526,7 +525,7 @@ class TelegramChannel(BaseChannel): pass except Exception as 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("Telegram error: {}", context.error) @@ -540,6 +539,6 @@ class TelegramChannel(BaseChannel): } if mime_type in ext_map: return ext_map[mime_type] - + type_map = {"image": ".jpg", "voice": ".ogg", "audio": ".mp3", "file": ""} return type_map.get(media_type, "") From 568a54ae3e8909003c37f41b96424300d73c8c2e Mon Sep 17 00:00:00 2001 From: Tanish Rajput Date: Thu, 26 Feb 2026 19:49:17 +0530 Subject: [PATCH 09/13] Initialize Matrix channel in ChannelManager when enabled in config --- nanobot/channels/manager.py | 12 ++++++++++++ nanobot/config/schema.py | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py index 77b7294..c8df6b2 100644 --- a/nanobot/channels/manager.py +++ b/nanobot/channels/manager.py @@ -136,6 +136,18 @@ class ChannelManager: logger.info("QQ channel enabled") except ImportError as e: logger.warning("QQ channel not available: {}", e) + + # Matrix channel + if self.config.channels.matrix.enabled: + try: + from nanobot.channels.matrix import MatrixChannel + self.channels["matrix"] = MatrixChannel( + self.config.channels.matrix, + self.bus, + ) + logger.info("Matrix channel enabled") + except ImportError as e: + logger.warning("Matrix channel not available: {}", e) async def _start_channel(self, name: str, channel: BaseChannel) -> None: """Start a channel and log any exceptions.""" diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 61aee96..cdc3b41 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -6,6 +6,7 @@ from typing import Literal from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings +from typing import Literal class Base(BaseModel): @@ -183,6 +184,24 @@ class QQConfig(Base): secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com 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 = "" # @bot:matrix.org + device_id: str = "" + # Enable Matrix E2EE support (encryption + encrypted room handling). + e2ee_enabled: bool = True + # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. + sync_stop_grace_seconds: int = 2 + # Max attachment size accepted for Matrix media handling (inbound + outbound). + max_media_bytes: int = 20 * 1024 * 1024 + 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.""" From aa2987be3eed79aa31cb4de9e49d7a751262d440 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:30:01 +0000 Subject: [PATCH 10/13] refactor: streamline Telegram media-group buffering --- nanobot/channels/telegram.py | 95 ++++++++++-------------------------- 1 file changed, 27 insertions(+), 68 deletions(-) diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ed77963..969d853 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -127,7 +127,7 @@ class TelegramChannel(BaseChannel): self._app: Application | None = None self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task - self._media_group_buffers: dict[str, dict[str, object]] = {} + self._media_group_buffers: dict[str, dict] = {} self._media_group_tasks: dict[str, asyncio.Task] = {} async def start(self) -> None: @@ -194,11 +194,9 @@ class TelegramChannel(BaseChannel): for chat_id in list(self._typing_tasks): self._stop_typing(chat_id) - # Cancel buffered media-group flush tasks - for key, task in list(self._media_group_tasks.items()): - if task and not task.done(): - task.cancel() - self._media_group_tasks.pop(key, None) + for task in self._media_group_tasks.values(): + task.cancel() + self._media_group_tasks.clear() self._media_group_buffers.clear() if self._app: @@ -409,43 +407,26 @@ class TelegramChannel(BaseChannel): str_chat_id = str(chat_id) - # Telegram media groups arrive as multiple messages sharing media_group_id. - # Buffer briefly and forward as one aggregated turn. - media_group_id = getattr(message, "media_group_id", None) - if media_group_id: - group_key = f"{str_chat_id}:{media_group_id}" - buffer = self._media_group_buffers.get(group_key) - if not buffer: - buffer = { - "sender_id": sender_id, - "chat_id": str_chat_id, - "contents": [], - "media": [], + # Telegram media groups: buffer briefly, forward as one aggregated turn. + if media_group_id := getattr(message, "media_group_id", None): + key = f"{str_chat_id}:{media_group_id}" + if key not in self._media_group_buffers: + 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, + "message_id": message.message_id, "user_id": user.id, + "username": user.username, "first_name": user.first_name, "is_group": message.chat.type != "private", - "media_group_id": media_group_id, }, } - self._media_group_buffers[group_key] = buffer self._start_typing(str_chat_id) - + buf = self._media_group_buffers[key] if content and content != "[empty message]": - cast_contents = buffer["contents"] - if isinstance(cast_contents, list): - cast_contents.append(content) - cast_media = buffer["media"] - if isinstance(cast_media, list): - cast_media.extend(media_paths) - - # Start one delayed flush task per media group. - if group_key not in self._media_group_tasks: - self._media_group_tasks[group_key] = asyncio.create_task( - self._flush_media_group(group_key) - ) + buf["contents"].append(content) + buf["media"].extend(media_paths) + if key not in self._media_group_tasks: + self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key)) return # Start typing indicator before processing @@ -466,42 +447,20 @@ class TelegramChannel(BaseChannel): } ) - async def _flush_media_group(self, group_key: str, delay_s: float = 0.6) -> None: - """Flush buffered Telegram media-group messages as one aggregated turn.""" + async def _flush_media_group(self, key: str) -> None: + """Wait briefly, then forward buffered media-group as one turn.""" try: - await asyncio.sleep(delay_s) - buffer = self._media_group_buffers.pop(group_key, None) - if not buffer: + await asyncio.sleep(0.6) + if not (buf := self._media_group_buffers.pop(key, None)): return - - sender_id = str(buffer.get("sender_id", "")) - chat_id = str(buffer.get("chat_id", "")) - contents = buffer.get("contents") - media = buffer.get("media") - metadata = buffer.get("metadata") - - content_parts = [c for c in (contents if isinstance(contents, list) else []) if isinstance(c, str) and c] - media_paths = [m for m in (media if isinstance(media, list) else []) if isinstance(m, str) and m] - - # De-duplicate while preserving order - seen = set() - unique_media: list[str] = [] - for m in media_paths: - if m in seen: - continue - seen.add(m) - unique_media.append(m) - - content = "\n".join(content_parts) if content_parts else "[empty message]" + content = "\n".join(buf["contents"]) or "[empty message]" await self._handle_message( - sender_id=sender_id, - chat_id=chat_id, - content=content, - media=unique_media, - metadata=metadata if isinstance(metadata, dict) else {}, + sender_id=buf["sender_id"], chat_id=buf["chat_id"], + content=content, media=list(dict.fromkeys(buf["media"])), + metadata=buf["metadata"], ) finally: - self._media_group_tasks.pop(group_key, None) + self._media_group_tasks.pop(key, None) def _start_typing(self, chat_id: str) -> None: """Start sending 'typing...' indicator for a chat.""" From d5808bf586c09a16ea6ba5ba5d48674d7466a38c Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:46:57 +0000 Subject: [PATCH 11/13] refactor: streamline workspace template sync --- nanobot/cli/commands.py | 7 +--- nanobot/utils/helpers.py | 70 ++++++++++++---------------------------- 2 files changed, 21 insertions(+), 56 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 9dee105..fc4c261 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -20,6 +20,7 @@ from prompt_toolkit.patch_stdout import patch_stdout from nanobot import __version__, __logo__ from nanobot.config.schema import Config +from nanobot.utils.helpers import sync_workspace_templates app = typer.Typer( name="nanobot", @@ -185,8 +186,6 @@ def onboard(): workspace.mkdir(parents=True, exist_ok=True) console.print(f"[green]✓[/green] Created workspace at {workspace}") - # Create default bootstrap files - from nanobot.utils.helpers import sync_workspace_templates sync_workspace_templates(workspace) console.print(f"\n{__logo__} nanobot is ready!") @@ -265,7 +264,6 @@ def gateway( console.print(f"{__logo__} Starting nanobot gateway on port {port}...") config = load_config() - from nanobot.utils.helpers import sync_workspace_templates sync_workspace_templates(config.workspace_path) bus = MessageBus() provider = _make_provider(config) @@ -420,7 +418,6 @@ def agent( from loguru import logger config = load_config() - from nanobot.utils.helpers import sync_workspace_templates sync_workspace_templates(config.workspace_path) bus = MessageBus() @@ -983,8 +980,6 @@ def status(): config_path = get_config_path() config = load_config() workspace = config.workspace_path - from nanobot.utils.helpers import sync_workspace_templates - sync_workspace_templates(workspace) console.print(f"{__logo__} nanobot Status\n") diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 83653ac..8963138 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -79,64 +79,34 @@ def parse_session_key(key: str) -> tuple[str, str]: return parts[0], parts[1] def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]: - """ - Synchronize default workspace template files from bundled templates. - Only creates files that do not exist. Returns list of added file names. - """ + """Sync bundled templates to workspace. Only creates missing files.""" from importlib.resources import files as pkg_files - from rich.console import Console - console = Console() - added = [] - try: - templates_dir = pkg_files("nanobot") / "templates" + tpl = pkg_files("nanobot") / "templates" except Exception: - # Fallback for some environments where pkg_files might fail + return [] + if not tpl.is_dir(): return [] - if not templates_dir.is_dir(): - return [] + added: list[str] = [] - # 1. Sync root templates - for item in templates_dir.iterdir(): - if not item.name.endswith(".md"): - continue - dest = workspace / item.name - if not dest.exists(): - try: - dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8") - added.append(item.name) - except Exception: - pass + def _write(src, dest: Path): + """Write src content (or empty string if None) to dest if missing.""" + if dest.exists(): + return + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8") + added.append(str(dest.relative_to(workspace))) - # 2. Sync memory templates - memory_dir = workspace / "memory" - memory_dir.mkdir(exist_ok=True) - - memory_src = templates_dir / "memory" / "MEMORY.md" - memory_dest = memory_dir / "MEMORY.md" - if memory_src.is_file() and not memory_dest.exists(): - try: - memory_dest.write_text(memory_src.read_text(encoding="utf-8"), encoding="utf-8") - added.append("memory/MEMORY.md") - except Exception: - pass - - # 3. History file (always ensure it exists) - history_file = memory_dir / "HISTORY.md" - if not history_file.exists(): - try: - history_file.write_text("", encoding="utf-8") - added.append("memory/HISTORY.md") - except Exception: - pass - - # 4. Ensure skills dir exists + for item in tpl.iterdir(): + if item.name.endswith(".md"): + _write(item, workspace / item.name) + _write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md") + _write(None, workspace / "memory" / "HISTORY.md") (workspace / "skills").mkdir(exist_ok=True) - # Print notices if files were added if added and not silent: + from rich.console import Console for name in added: - console.print(f" [dim]Created {name}[/dim]") - - return added \ No newline at end of file + Console().print(f" [dim]Created {name}[/dim]") + return added From 858a62dd9bda42696ad07a7e6453608ca9ece34d Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:50:12 +0000 Subject: [PATCH 12/13] =?UTF-8?q?refactor:=20slim=20down=20helpers.py=20?= =?UTF-8?q?=E2=80=94=20remove=20dead=20code,=20compress=20docstrings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- nanobot/utils/helpers.py | 65 +++++++--------------------------------- 2 files changed, 11 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 71922fb..251181b 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ ⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines. -📏 Real-time line count: **3,932 lines** (run `bash core_agent_lines.sh` to verify anytime) +📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime) ## 📢 News diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 8963138..8322bc8 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -1,83 +1,39 @@ """Utility functions for nanobot.""" +import re from pathlib import Path from datetime import datetime + def ensure_dir(path: Path) -> Path: - """Ensure a directory exists, creating it if necessary.""" + """Ensure directory exists, return it.""" path.mkdir(parents=True, exist_ok=True) return path def get_data_path() -> Path: - """Get the nanobot data directory (~/.nanobot).""" + """~/.nanobot data directory.""" return ensure_dir(Path.home() / ".nanobot") def get_workspace_path(workspace: str | None = None) -> Path: - """ - Get the workspace path. - - Args: - workspace: Optional workspace path. Defaults to ~/.nanobot/workspace. - - Returns: - Expanded and ensured workspace path. - """ - if workspace: - path = Path(workspace).expanduser() - else: - path = Path.home() / ".nanobot" / "workspace" + """Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace.""" + path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace" return ensure_dir(path) -def get_sessions_path() -> Path: - """Get the sessions storage directory.""" - return ensure_dir(get_data_path() / "sessions") - - -def get_skills_path(workspace: Path | None = None) -> Path: - """Get the skills directory within the workspace.""" - ws = workspace or get_workspace_path() - return ensure_dir(ws / "skills") - - def timestamp() -> str: - """Get current timestamp in ISO format.""" + """Current ISO timestamp.""" return datetime.now().isoformat() -def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str: - """Truncate a string to max length, adding suffix if truncated.""" - if len(s) <= max_len: - return s - return s[: max_len - len(suffix)] + suffix - +_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]') def safe_filename(name: str) -> str: - """Convert a string to a safe filename.""" - # Replace unsafe characters - unsafe = '<>:"/\\|?*' - for char in unsafe: - name = name.replace(char, "_") - return name.strip() + """Replace unsafe path characters with underscores.""" + return _UNSAFE_CHARS.sub("_", name).strip() -def parse_session_key(key: str) -> tuple[str, str]: - """ - Parse a session key into channel and chat_id. - - Args: - key: Session key in format "channel:chat_id" - - Returns: - Tuple of (channel, chat_id) - """ - parts = key.split(":", 1) - if len(parts) != 2: - raise ValueError(f"Invalid session key: {key}") - return parts[0], parts[1] - 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 @@ -91,7 +47,6 @@ def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str] added: list[str] = [] def _write(src, dest: Path): - """Write src content (or empty string if None) to dest if missing.""" if dest.exists(): return dest.parent.mkdir(parents=True, exist_ok=True) From 12f3365103c4aa33d5acaea01dfd30e66a6866e2 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Fri, 27 Feb 2026 09:53:31 +0000 Subject: [PATCH 13/13] fix: remove duplicate import, tidy MatrixConfig comments --- nanobot/config/schema.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 4889783..1ff9782 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -6,7 +6,6 @@ from typing import Literal from pydantic import BaseModel, Field, ConfigDict from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings -from typing import Literal class Base(BaseModel): @@ -187,18 +186,14 @@ class QQConfig(Base): class MatrixConfig(Base): """Matrix (Element) channel configuration.""" - enabled: bool = False homeserver: str = "https://matrix.org" access_token: str = "" - user_id: str = "" # @bot:matrix.org + user_id: str = "" # e.g. @bot:matrix.org device_id: str = "" - # Enable Matrix E2EE support (encryption + encrypted room handling). - e2ee_enabled: bool = True - # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback. - sync_stop_grace_seconds: int = 2 - # Max attachment size accepted for Matrix media handling (inbound + outbound). - max_media_bytes: int = 20 * 1024 * 1024 + 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)