From 85c56d7410ab4eed78ec70d75489cf453afcfbb3 Mon Sep 17 00:00:00 2001 From: Renato Machado Date: Mon, 9 Mar 2026 01:37:35 +0000 Subject: [PATCH 1/5] feat: add "restart" command --- nanobot/agent/loop.py | 11 +++++++++++ nanobot/channels/telegram.py | 2 ++ 2 files changed, 13 insertions(+) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index ca9a06e..5311921 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -5,6 +5,8 @@ from __future__ import annotations import asyncio import json import re +import os +import sys import weakref from contextlib import AsyncExitStack from pathlib import Path @@ -392,6 +394,15 @@ class AgentLoop: if cmd == "/help": return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands") + if cmd == "/restart": + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="🔄 Restarting..." + )) + async def _r(): + await asyncio.sleep(1) + os.execv(sys.executable, [sys.executable] + sys.argv) + asyncio.create_task(_r()) + return None unconsolidated = len(session.messages) - session.last_consolidated if (unconsolidated >= self.memory_window and session.key not in self._consolidating): diff --git a/nanobot/channels/telegram.py b/nanobot/channels/telegram.py index ecb1440..f37ab1d 100644 --- a/nanobot/channels/telegram.py +++ b/nanobot/channels/telegram.py @@ -162,6 +162,7 @@ class TelegramChannel(BaseChannel): BotCommand("new", "Start a new conversation"), BotCommand("stop", "Stop the current task"), BotCommand("help", "Show available commands"), + BotCommand("restart", "Restart the bot"), ] def __init__( @@ -223,6 +224,7 @@ class TelegramChannel(BaseChannel): self._app.add_handler(CommandHandler("start", self._on_start)) self._app.add_handler(CommandHandler("new", self._forward_command)) self._app.add_handler(CommandHandler("stop", self._forward_command)) + self._app.add_handler(CommandHandler("restart", self._forward_command)) self._app.add_handler(CommandHandler("help", self._on_help)) # Add message handler for text, photos, voice, documents From 711903bc5fd00be72009c0b04ab1e42d46239311 Mon Sep 17 00:00:00 2001 From: Zek Date: Mon, 9 Mar 2026 17:54:02 +0800 Subject: [PATCH 2/5] feat(feishu): add global group mention policy - Add group_policy config: 'open' (default) or 'mention' - 'open': Respond to all group messages (backward compatible) - 'mention': Only respond when @mentioned in any group - Auto-detect bot mentions by pattern matching: * If open_id configured: match against mentions * Otherwise: detect bot by empty user_id + ou_ open_id pattern - Support @_all mentions - Private chats unaffected (always respond) - Clean implementation with minimal logging docs: update Feishu README with group policy documentation --- README.md | 15 +++++++- nanobot/channels/feishu.py | 78 ++++++++++++++++++++++++++++++++++++++ nanobot/config/schema.py | 2 + 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f169bd7..29221a7 100644 --- a/README.md +++ b/README.md @@ -482,7 +482,8 @@ Uses **WebSocket** long connection — no public IP required. "appSecret": "xxx", "encryptKey": "", "verificationToken": "", - "allowFrom": ["ou_YOUR_OPEN_ID"] + "allowFrom": ["ou_YOUR_OPEN_ID"], + "groupPolicy": "open" } } } @@ -491,6 +492,18 @@ Uses **WebSocket** long connection — no public IP required. > `encryptKey` and `verificationToken` are optional for Long Connection mode. > `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users. +**Group Chat Policy** (optional): + +| Option | Values | Default | Description | +|--------|--------|---------|-------------| +| `groupPolicy` | `"open"` | `"open"` | Respond to all group messages (backward compatible) | +| | `"mention"` | | Only respond when @mentioned | + +> [!NOTE] +> - `"open"`: Respond to all messages in all groups +> - `"mention"`: Only respond when @mentioned in any group +> - Private chats are unaffected (always respond) + **3. Run** ```bash diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index a637025..78bf2df 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -352,6 +352,74 @@ class FeishuChannel(BaseChannel): self._running = False logger.info("Feishu bot stopped") + def _get_bot_open_id_sync(self) -> str | None: + """Get bot's own open_id for mention detection. + + 飞书 SDK 没有直接的 bot info API,从配置或缓存获取。 + """ + # 尝试从配置获取 open_id(用户可以在配置中指定) + if hasattr(self.config, 'open_id') and self.config.open_id: + return self.config.open_id + + return None + + def _is_bot_mentioned(self, message: Any, bot_open_id: str | None) -> bool: + """Check if bot is mentioned in the message. + + 飞书 mentions 数组包含被@的对象。匹配策略: + 1. 如果配置了 bot_open_id,则匹配 open_id + 2. 否则,检查 mentions 中是否有空的 user_id(bot 的特征) + + Handles: + - Direct mentions in message.mentions + - @all mentions + """ + # Check @all + raw_content = message.content or "" + if "@_all" in raw_content: + logger.debug("Feishu: @_all mention detected") + return True + + # Check mentions array + mentions = message.mentions if hasattr(message, 'mentions') and message.mentions else [] + if mentions: + if bot_open_id: + # 策略 1: 匹配配置的 open_id + for mention in mentions: + if mention.id: + open_id = getattr(mention.id, 'open_id', None) + if open_id == bot_open_id: + logger.debug("Feishu: bot mention matched") + return True + else: + # 策略 2: 检查 bot 特征 - user_id 为空且 open_id 存在 + for mention in mentions: + if mention.id: + user_id = getattr(mention.id, 'user_id', None) + open_id = getattr(mention.id, 'open_id', None) + # Bot 的特征:user_id 为空字符串,open_id 存在 + if user_id == '' and open_id and open_id.startswith('ou_'): + logger.debug("Feishu: bot mention matched") + return True + + return False + + def _should_respond_in_group( + self, + chat_id: str, + mentioned: bool + ) -> tuple[bool, str]: + """Determine if bot should respond in a group chat. + + Returns: + (should_respond, reason) + """ + # Check mention requirement + if self.config.group_policy == "mention" and not mentioned: + return False, "not mentioned in group" + + return True, "" + def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji @@ -892,6 +960,16 @@ class FeishuChannel(BaseChannel): chat_type = message.chat_type msg_type = message.message_type + # Check group policy and mention requirement + if chat_type == "group": + bot_open_id = self._get_bot_open_id_sync() + mentioned = self._is_bot_mentioned(message, bot_open_id) + should_respond, reason = self._should_respond_in_group(chat_id, mentioned) + + if not should_respond: + logger.debug("Feishu: ignoring group message - {}", reason) + return + # Add reaction await self._add_reaction(message_id, self.config.react_emoji) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 803cb61..6b2eb35 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -47,6 +47,8 @@ class FeishuConfig(Base): react_emoji: str = ( "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) ) + # Group chat settings + group_policy: Literal["open", "mention"] = "open" # Group response policy (default: open for backward compatibility) class DingTalkConfig(Base): From 64aeeceed02aadb19e51f82d71674024baec4b95 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 04:33:51 +0000 Subject: [PATCH 3/5] Add /restart command: restart the bot process from any channel --- nanobot/agent/loop.py | 43 +++++++++++++------- tests/test_restart_command.py | 76 +++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 15 deletions(-) create mode 100644 tests/test_restart_command.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 597f852..5fe0ee0 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -4,8 +4,8 @@ from __future__ import annotations import asyncio import json -import re import os +import re import sys from contextlib import AsyncExitStack from pathlib import Path @@ -258,8 +258,11 @@ class AgentLoop: except asyncio.TimeoutError: continue - if msg.content.strip().lower() == "/stop": + cmd = msg.content.strip().lower() + if cmd == "/stop": await self._handle_stop(msg) + elif cmd == "/restart": + await self._handle_restart(msg) else: task = asyncio.create_task(self._dispatch(msg)) self._active_tasks.setdefault(msg.session_key, []).append(task) @@ -276,11 +279,23 @@ class AgentLoop: pass sub_cancelled = await self.subagents.cancel_by_session(msg.session_key) total = cancelled + sub_cancelled - content = f"⏹ Stopped {total} task(s)." if total else "No active task to stop." + content = f"Stopped {total} task(s)." if total else "No active task to stop." await self.bus.publish_outbound(OutboundMessage( channel=msg.channel, chat_id=msg.chat_id, content=content, )) + async def _handle_restart(self, msg: InboundMessage) -> None: + """Restart the process in-place via os.execv.""" + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="Restarting...", + )) + + async def _do_restart(): + await asyncio.sleep(1) + os.execv(sys.executable, [sys.executable] + sys.argv) + + asyncio.create_task(_do_restart()) + async def _dispatch(self, msg: InboundMessage) -> None: """Process a message under the global lock.""" async with self._processing_lock: @@ -375,18 +390,16 @@ class AgentLoop: return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, content="New session started.") if cmd == "/help": - return OutboundMessage(channel=msg.channel, chat_id=msg.chat_id, - content="🐈 nanobot commands:\n/new — Start a new conversation\n/stop — Stop the current task\n/help — Show available commands") - if cmd == "/restart": - await self.bus.publish_outbound(OutboundMessage( - channel=msg.channel, chat_id=msg.chat_id, content="🔄 Restarting..." - )) - async def _r(): - await asyncio.sleep(1) - os.execv(sys.executable, [sys.executable] + sys.argv) - asyncio.create_task(_r()) - return None - + lines = [ + "🐈 nanobot commands:", + "/new — Start a new conversation", + "/stop — Stop the current task", + "/restart — Restart the bot", + "/help — Show available commands", + ] + return OutboundMessage( + channel=msg.channel, chat_id=msg.chat_id, content="\n".join(lines), + ) await self.memory_consolidator.maybe_consolidate_by_tokens(session) self._set_tool_context(msg.channel, msg.chat_id, msg.metadata.get("message_id")) diff --git a/tests/test_restart_command.py b/tests/test_restart_command.py new file mode 100644 index 0000000..c495347 --- /dev/null +++ b/tests/test_restart_command.py @@ -0,0 +1,76 @@ +"""Tests for /restart slash command.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock, patch + +import pytest + +from nanobot.bus.events import InboundMessage + + +def _make_loop(): + """Create a minimal AgentLoop with mocked dependencies.""" + from nanobot.agent.loop import AgentLoop + from nanobot.bus.queue import MessageBus + + bus = MessageBus() + provider = MagicMock() + provider.get_default_model.return_value = "test-model" + workspace = MagicMock() + workspace.__truediv__ = MagicMock(return_value=MagicMock()) + + with patch("nanobot.agent.loop.ContextBuilder"), \ + patch("nanobot.agent.loop.SessionManager"), \ + patch("nanobot.agent.loop.SubagentManager"): + loop = AgentLoop(bus=bus, provider=provider, workspace=workspace) + return loop, bus + + +class TestRestartCommand: + + @pytest.mark.asyncio + async def test_restart_sends_message_and_calls_execv(self): + loop, bus = _make_loop() + msg = InboundMessage(channel="cli", sender_id="user", chat_id="direct", content="/restart") + + with patch("nanobot.agent.loop.os.execv") as mock_execv: + await loop._handle_restart(msg) + out = await asyncio.wait_for(bus.consume_outbound(), timeout=1.0) + assert "Restarting" in out.content + + await asyncio.sleep(1.5) + mock_execv.assert_called_once() + + @pytest.mark.asyncio + async def test_restart_intercepted_in_run_loop(self): + """Verify /restart is handled at the run-loop level, not inside _dispatch.""" + loop, bus = _make_loop() + msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/restart") + + with patch.object(loop, "_handle_restart") as mock_handle: + mock_handle.return_value = None + await bus.publish_inbound(msg) + + loop._running = True + run_task = asyncio.create_task(loop.run()) + await asyncio.sleep(0.1) + loop._running = False + run_task.cancel() + try: + await run_task + except asyncio.CancelledError: + pass + + mock_handle.assert_called_once() + + @pytest.mark.asyncio + async def test_help_includes_restart(self): + loop, bus = _make_loop() + msg = InboundMessage(channel="telegram", sender_id="u1", chat_id="c1", content="/help") + + response = await loop._process_message(msg) + + assert response is not None + assert "/restart" in response.content From 95c741db6293f49ad41343432b1e9649aa4d1ef8 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 04:35:34 +0000 Subject: [PATCH 4/5] docs: update nanobot key features --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8dba2d7..e887828 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ ## Key Features of nanobot: -🪶 **Ultra-Lightweight**: Just ~4,000 lines of core agent code — 99% smaller than Clawdbot. +🪶 **Ultra-Lightweight**: A super lightweight implementation of OpenClaw — 99% smaller, significantly faster. 🔬 **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research. From bd1ce8f1440311d42dcc22c60153964f64d27a94 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 12 Mar 2026 04:45:57 +0000 Subject: [PATCH 5/5] Simplify feishu group_policy: default to mention, clean up mention detection --- README.md | 15 +------ nanobot/channels/feishu.py | 91 ++++++++------------------------------ nanobot/config/schema.py | 3 +- 3 files changed, 22 insertions(+), 87 deletions(-) diff --git a/README.md b/README.md index 155920f..dccb4be 100644 --- a/README.md +++ b/README.md @@ -503,7 +503,7 @@ Uses **WebSocket** long connection — no public IP required. "encryptKey": "", "verificationToken": "", "allowFrom": ["ou_YOUR_OPEN_ID"], - "groupPolicy": "open" + "groupPolicy": "mention" } } } @@ -511,18 +511,7 @@ Uses **WebSocket** long connection — no public IP required. > `encryptKey` and `verificationToken` are optional for Long Connection mode. > `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users. - -**Group Chat Policy** (optional): - -| Option | Values | Default | Description | -|--------|--------|---------|-------------| -| `groupPolicy` | `"open"` | `"open"` | Respond to all group messages (backward compatible) | -| | `"mention"` | | Only respond when @mentioned | - -> [!NOTE] -> - `"open"`: Respond to all messages in all groups -> - `"mention"`: Only respond when @mentioned in any group -> - Private chats are unaffected (always respond) +> `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all group messages). Private chats always respond. **3. Run** diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 4919e3c..780227a 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -352,73 +352,26 @@ class FeishuChannel(BaseChannel): self._running = False logger.info("Feishu bot stopped") - def _get_bot_open_id_sync(self) -> str | None: - """Get bot's own open_id for mention detection. - - 飞书 SDK 没有直接的 bot info API,从配置或缓存获取。 - """ - # 尝试从配置获取 open_id(用户可以在配置中指定) - if hasattr(self.config, 'open_id') and self.config.open_id: - return self.config.open_id - - return None - - def _is_bot_mentioned(self, message: Any, bot_open_id: str | None) -> bool: - """Check if bot is mentioned in the message. - - 飞书 mentions 数组包含被@的对象。匹配策略: - 1. 如果配置了 bot_open_id,则匹配 open_id - 2. 否则,检查 mentions 中是否有空的 user_id(bot 的特征) - - Handles: - - Direct mentions in message.mentions - - @all mentions - """ - # Check @all + def _is_bot_mentioned(self, message: Any) -> bool: + """Check if the bot is @mentioned in the message.""" raw_content = message.content or "" if "@_all" in raw_content: - logger.debug("Feishu: @_all mention detected") return True - - # Check mentions array - mentions = message.mentions if hasattr(message, 'mentions') and message.mentions else [] - if mentions: - if bot_open_id: - # 策略 1: 匹配配置的 open_id - for mention in mentions: - if mention.id: - open_id = getattr(mention.id, 'open_id', None) - if open_id == bot_open_id: - logger.debug("Feishu: bot mention matched") - return True - else: - # 策略 2: 检查 bot 特征 - user_id 为空且 open_id 存在 - for mention in mentions: - if mention.id: - user_id = getattr(mention.id, 'user_id', None) - open_id = getattr(mention.id, 'open_id', None) - # Bot 的特征:user_id 为空字符串,open_id 存在 - if user_id == '' and open_id and open_id.startswith('ou_'): - logger.debug("Feishu: bot mention matched") - return True - + + for mention in getattr(message, "mentions", None) or []: + mid = getattr(mention, "id", None) + if not mid: + continue + # Bot mentions have an empty user_id with a valid open_id + if getattr(mid, "user_id", None) == "" and (getattr(mid, "open_id", None) or "").startswith("ou_"): + return True return False - def _should_respond_in_group( - self, - chat_id: str, - mentioned: bool - ) -> tuple[bool, str]: - """Determine if bot should respond in a group chat. - - Returns: - (should_respond, reason) - """ - # Check mention requirement - if self.config.group_policy == "mention" and not mentioned: - return False, "not mentioned in group" - - return True, "" + def _is_group_message_for_bot(self, message: Any) -> bool: + """Allow group messages when policy is open or bot is @mentioned.""" + if self.config.group_policy == "open": + return True + return self._is_bot_mentioned(message) def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" @@ -961,16 +914,10 @@ class FeishuChannel(BaseChannel): chat_type = message.chat_type msg_type = message.message_type - # Check group policy and mention requirement - if chat_type == "group": - bot_open_id = self._get_bot_open_id_sync() - mentioned = self._is_bot_mentioned(message, bot_open_id) - should_respond, reason = self._should_respond_in_group(chat_id, mentioned) - - if not should_respond: - logger.debug("Feishu: ignoring group message - {}", reason) - return - + if chat_type == "group" and not self._is_group_message_for_bot(message): + logger.debug("Feishu: skipping group message (not mentioned)") + return + # Add reaction await self._add_reaction(message_id, self.config.react_emoji) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 592a93c..55e109e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -48,8 +48,7 @@ class FeishuConfig(Base): react_emoji: str = ( "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE) ) - # Group chat settings - group_policy: Literal["open", "mention"] = "open" # Group response policy (default: open for backward compatibility) + group_policy: Literal["open", "mention"] = "mention" # "mention" responds when @mentioned, "open" responds to all class DingTalkConfig(Base):