diff --git a/.gitignore b/.gitignore index d7b930d..742d593 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,4 @@ __pycache__/ poetry.lock .pytest_cache/ botpy.log -tests/ + diff --git a/README.md b/README.md index 33cdeee..6c9304d 100644 --- a/README.md +++ b/README.md @@ -293,12 +293,18 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allowFrom": ["YOUR_USER_ID"], + "groupPolicy": "mention" } } } ``` +> `groupPolicy` controls how the bot responds in group channels: +> - `"mention"` (default) — Only respond when @mentioned +> - `"open"` — Respond to all messages +> DMs always respond when the sender is in `allowFrom`. + **5. Invite the bot** - OAuth2 → URL Generator - Scopes: `bot` diff --git a/nanobot/agent/tools/base.py b/nanobot/agent/tools/base.py index 8dd82c7..051fc9a 100644 --- a/nanobot/agent/tools/base.py +++ b/nanobot/agent/tools/base.py @@ -54,6 +54,8 @@ class Tool(ABC): def validate_params(self, params: dict[str, Any]) -> list[str]: """Validate tool parameters against JSON schema. Returns error list (empty if valid).""" + if not isinstance(params, dict): + return [f"parameters must be an object, got {type(params).__name__}"] schema = self.parameters or {} if schema.get("type", "object") != "object": raise ValueError(f"Schema must be object type, got {schema.get('type')!r}") diff --git a/nanobot/agent/tools/cron.py b/nanobot/agent/tools/cron.py index 13b1e12..f8e737b 100644 --- a/nanobot/agent/tools/cron.py +++ b/nanobot/agent/tools/cron.py @@ -122,7 +122,10 @@ class CronTool(Tool): elif at: from datetime import datetime - dt = datetime.fromisoformat(at) + try: + dt = datetime.fromisoformat(at) + except ValueError: + return f"Error: invalid ISO datetime format '{at}'. Expected format: YYYY-MM-DDTHH:MM:SS" at_ms = int(dt.timestamp() * 1000) schedule = CronSchedule(kind="at", at_ms=at_ms) delete_after = True diff --git a/nanobot/agent/tools/filesystem.py b/nanobot/agent/tools/filesystem.py index bbdd49c..7b0b867 100644 --- a/nanobot/agent/tools/filesystem.py +++ b/nanobot/agent/tools/filesystem.py @@ -26,6 +26,8 @@ def _resolve_path( class ReadFileTool(Tool): """Tool to read file contents.""" + _MAX_CHARS = 128_000 # ~128 KB — prevents OOM from reading huge files into LLM context + def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None): self._workspace = workspace self._allowed_dir = allowed_dir @@ -54,7 +56,16 @@ class ReadFileTool(Tool): if not file_path.is_file(): return f"Error: Not a file: {path}" + size = file_path.stat().st_size + if size > self._MAX_CHARS * 4: # rough upper bound (UTF-8 chars ≤ 4 bytes) + return ( + f"Error: File too large ({size:,} bytes). " + f"Use exec tool with head/tail/grep to read portions." + ) + content = file_path.read_text(encoding="utf-8") + if len(content) > self._MAX_CHARS: + return content[: self._MAX_CHARS] + f"\n\n... (truncated — file is {len(content):,} chars, limit {self._MAX_CHARS:,})" return content except PermissionError as e: return f"Error: {e}" diff --git a/nanobot/channels/discord.py b/nanobot/channels/discord.py index 57e5922..900c17b 100644 --- a/nanobot/channels/discord.py +++ b/nanobot/channels/discord.py @@ -54,6 +54,7 @@ class DiscordChannel(BaseChannel): self._heartbeat_task: asyncio.Task | None = None self._typing_tasks: dict[str, asyncio.Task] = {} self._http: httpx.AsyncClient | None = None + self._bot_user_id: str | None = None async def start(self) -> None: """Start the Discord gateway connection.""" @@ -170,6 +171,10 @@ class DiscordChannel(BaseChannel): await self._identify() elif op == 0 and event_type == "READY": logger.info("Discord gateway READY") + # Capture bot user ID for mention detection + user_data = payload.get("user") or {} + self._bot_user_id = user_data.get("id") + logger.info("Discord bot connected as user {}", self._bot_user_id) elif op == 0 and event_type == "MESSAGE_CREATE": await self._handle_message_create(payload) elif op == 7: @@ -226,6 +231,7 @@ class DiscordChannel(BaseChannel): sender_id = str(author.get("id", "")) channel_id = str(payload.get("channel_id", "")) content = payload.get("content") or "" + guild_id = payload.get("guild_id") if not sender_id or not channel_id: return @@ -233,6 +239,11 @@ class DiscordChannel(BaseChannel): if not self.is_allowed(sender_id): return + # Check group channel policy (DMs always respond if is_allowed passes) + if guild_id is not None: + if not self._should_respond_in_group(payload, content): + return + content_parts = [content] if content else [] media_paths: list[str] = [] media_dir = Path.home() / ".nanobot" / "media" @@ -269,11 +280,32 @@ class DiscordChannel(BaseChannel): media=media_paths, metadata={ "message_id": str(payload.get("id", "")), - "guild_id": payload.get("guild_id"), + "guild_id": guild_id, "reply_to": reply_to, }, ) + def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool: + """Check if bot should respond in a group channel based on policy.""" + if self.config.group_policy == "open": + return True + + if self.config.group_policy == "mention": + # Check if bot was mentioned in the message + if self._bot_user_id: + # Check mentions array + mentions = payload.get("mentions") or [] + for mention in mentions: + if str(mention.get("id")) == self._bot_user_id: + return True + # Also check content for mention format <@USER_ID> + if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content: + return True + logger.debug("Discord message in {} ignored (bot not mentioned)", payload.get("channel_id")) + return False + + return True + async def _start_typing(self, channel_id: str) -> None: """Start periodic typing indicator for a channel.""" await self._stop_typing(channel_id) diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py index 0a0a5e4..fcb70a8 100644 --- a/nanobot/channels/feishu.py +++ b/nanobot/channels/feishu.py @@ -16,26 +16,9 @@ from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel from nanobot.config.schema import FeishuConfig -try: - import lark_oapi as lark - from lark_oapi.api.im.v1 import ( - CreateFileRequest, - CreateFileRequestBody, - CreateImageRequest, - CreateImageRequestBody, - CreateMessageReactionRequest, - CreateMessageReactionRequestBody, - CreateMessageRequest, - CreateMessageRequestBody, - Emoji, - GetMessageResourceRequest, - P2ImMessageReceiveV1, - ) - FEISHU_AVAILABLE = True -except ImportError: - FEISHU_AVAILABLE = False - lark = None - Emoji = None +import importlib.util + +FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None # Message type display mapping MSG_TYPE_MAP = { @@ -280,6 +263,7 @@ class FeishuChannel(BaseChannel): logger.error("Feishu app_id and app_secret not configured") return + import lark_oapi as lark self._running = True self._loop = asyncio.get_running_loop() @@ -306,16 +290,28 @@ class FeishuChannel(BaseChannel): log_level=lark.LogLevel.INFO ) - # Start WebSocket client in a separate thread with reconnect loop + # Start WebSocket client in a separate thread with reconnect loop. + # A dedicated event loop is created for this thread so that lark_oapi's + # module-level `loop = asyncio.get_event_loop()` picks up an idle loop + # instead of the already-running main asyncio loop, which would cause + # "This event loop is already running" errors. def run_ws(): - while self._running: - try: - self._ws_client.start() - except Exception as e: - logger.warning("Feishu WebSocket error: {}", e) - if self._running: - import time - time.sleep(5) + import time + import lark_oapi.ws.client as _lark_ws_client + ws_loop = asyncio.new_event_loop() + asyncio.set_event_loop(ws_loop) + # Patch the module-level loop used by lark's ws Client.start() + _lark_ws_client.loop = ws_loop + try: + while self._running: + try: + self._ws_client.start() + except Exception as e: + logger.warning("Feishu WebSocket error: {}", e) + if self._running: + time.sleep(5) + finally: + ws_loop.close() self._ws_thread = threading.Thread(target=run_ws, daemon=True) self._ws_thread.start() @@ -340,6 +336,7 @@ class FeishuChannel(BaseChannel): def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None: """Sync helper for adding reaction (runs in thread pool).""" + from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji try: request = CreateMessageReactionRequest.builder() \ .message_id(message_id) \ @@ -364,7 +361,7 @@ class FeishuChannel(BaseChannel): Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART """ - if not self._client or not Emoji: + if not self._client: return loop = asyncio.get_running_loop() @@ -456,6 +453,7 @@ class FeishuChannel(BaseChannel): def _upload_image_sync(self, file_path: str) -> str | None: """Upload an image to Feishu and return the image_key.""" + from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody try: with open(file_path, "rb") as f: request = CreateImageRequest.builder() \ @@ -479,6 +477,7 @@ class FeishuChannel(BaseChannel): def _upload_file_sync(self, file_path: str) -> str | None: """Upload a file to Feishu and return the file_key.""" + from lark_oapi.api.im.v1 import CreateFileRequest, CreateFileRequestBody ext = os.path.splitext(file_path)[1].lower() file_type = self._FILE_TYPE_MAP.get(ext, "stream") file_name = os.path.basename(file_path) @@ -506,6 +505,7 @@ class FeishuChannel(BaseChannel): def _download_image_sync(self, message_id: str, image_key: str) -> tuple[bytes | None, str | None]: """Download an image from Feishu message by message_id and image_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest try: request = GetMessageResourceRequest.builder() \ .message_id(message_id) \ @@ -530,6 +530,13 @@ class FeishuChannel(BaseChannel): self, message_id: str, file_key: str, resource_type: str = "file" ) -> tuple[bytes | None, str | None]: """Download a file/audio/media from a Feishu message by message_id and file_key.""" + from lark_oapi.api.im.v1 import GetMessageResourceRequest + + # Feishu API only accepts 'image' or 'file' as type parameter + # Convert 'audio' to 'file' for API compatibility + if resource_type == "audio": + resource_type = "file" + try: request = ( GetMessageResourceRequest.builder() @@ -598,6 +605,7 @@ class FeishuChannel(BaseChannel): def _send_message_sync(self, receive_id_type: str, receive_id: str, msg_type: str, content: str) -> bool: """Send a single message (text/image/file/interactive) synchronously.""" + from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody try: request = CreateMessageRequest.builder() \ .receive_id_type(receive_id_type) \ diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py index 7b171bc..6c58049 100644 --- a/nanobot/channels/qq.py +++ b/nanobot/channels/qq.py @@ -56,6 +56,7 @@ class QQChannel(BaseChannel): self.config: QQConfig = config self._client: "botpy.Client | None" = None self._processed_ids: deque = deque(maxlen=1000) + self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重 async def start(self) -> None: """Start the QQ bot.""" @@ -102,11 +103,13 @@ class QQChannel(BaseChannel): return try: msg_id = msg.metadata.get("message_id") + self._msg_seq += 1 # 递增序列号 await self._client.api.post_c2c_message( openid=msg.chat_id, msg_type=0, content=msg.content, msg_id=msg_id, + msg_seq=self._msg_seq, # 添加序列号避免去重 ) except Exception as e: logger.error("Error sending QQ message: {}", e) @@ -133,3 +136,4 @@ class QQChannel(BaseChannel): ) except Exception: logger.exception("Error handling QQ message") + diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 64e60dc..9f2e5b3 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -66,6 +66,7 @@ class DiscordConfig(Base): allow_from: list[str] = Field(default_factory=list) # Allowed user IDs gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json" intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT + group_policy: Literal["mention", "open"] = "mention" class MatrixConfig(Base): diff --git a/nanobot/providers/custom_provider.py b/nanobot/providers/custom_provider.py index 56e6270..66df734 100644 --- a/nanobot/providers/custom_provider.py +++ b/nanobot/providers/custom_provider.py @@ -2,6 +2,7 @@ from __future__ import annotations +import uuid from typing import Any import json_repair @@ -15,7 +16,12 @@ class CustomProvider(LLMProvider): def __init__(self, api_key: str = "no-key", api_base: str = "http://localhost:8000/v1", default_model: str = "default"): super().__init__(api_key, api_base) self.default_model = default_model - self._client = AsyncOpenAI(api_key=api_key, base_url=api_base) + # Keep affinity stable for this provider instance to improve backend cache locality. + self._client = AsyncOpenAI( + api_key=api_key, + base_url=api_base, + default_headers={"x-session-affinity": uuid.uuid4().hex}, + ) async def chat(self, messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7, diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index b6afa65..d04e210 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider): "parallel_tool_calls": True, } + if reasoning_effort: + body["reasoning"] = {"effort": reasoning_effort} + if tools: body["tools"] = _convert_tools(tools) diff --git a/pyproject.toml b/pyproject.toml index 4199af1..d86bb28 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,7 @@ dependencies = [ "prompt-toolkit>=3.0.50,<4.0.0", "mcp>=1.26.0,<2.0.0", "json-repair>=0.57.0,<1.0.0", + "chardet>=3.0.2,<6.0.0", "openai>=2.8.0", ] @@ -55,6 +56,9 @@ dev = [ "pytest>=9.0.0,<10.0.0", "pytest-asyncio>=1.3.0,<2.0.0", "ruff>=0.1.0", + "matrix-nio[e2e]>=0.25.2", + "mistune>=3.0.0,<4.0.0", + "nh3>=0.2.17,<1.0.0", ] [project.scripts] diff --git a/tests/test_context_prompt_cache.py b/tests/test_context_prompt_cache.py index 9afcc7d..ce796e2 100644 --- a/tests/test_context_prompt_cache.py +++ b/tests/test_context_prompt_cache.py @@ -40,7 +40,7 @@ def test_system_prompt_stays_stable_when_clock_changes(tmp_path, monkeypatch) -> def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: - """Runtime metadata should be a separate user message before the actual user message.""" + """Runtime metadata should be merged with the user message.""" workspace = _make_workspace(tmp_path) builder = ContextBuilder(workspace) @@ -54,13 +54,12 @@ def test_runtime_context_is_separate_untrusted_user_message(tmp_path) -> None: assert messages[0]["role"] == "system" assert "## Current Session" not in messages[0]["content"] - assert messages[-2]["role"] == "user" - runtime_content = messages[-2]["content"] - assert isinstance(runtime_content, str) - assert ContextBuilder._RUNTIME_CONTEXT_TAG in runtime_content - assert "Current Time:" in runtime_content - assert "Channel: cli" in runtime_content - assert "Chat ID: direct" in runtime_content - + # Runtime context is now merged with user message into a single message assert messages[-1]["role"] == "user" - assert messages[-1]["content"] == "Return exactly: OK" + user_content = messages[-1]["content"] + assert isinstance(user_content, str) + assert ContextBuilder._RUNTIME_CONTEXT_TAG in user_content + assert "Current Time:" in user_content + assert "Channel: cli" in user_content + assert "Chat ID: direct" in user_content + assert "Return exactly: OK" in user_content diff --git a/tests/test_cron_commands.py b/tests/test_cron_commands.py deleted file mode 100644 index bce1ef5..0000000 --- a/tests/test_cron_commands.py +++ /dev/null @@ -1,29 +0,0 @@ -from typer.testing import CliRunner - -from nanobot.cli.commands import app - -runner = CliRunner() - - -def test_cron_add_rejects_invalid_timezone(monkeypatch, tmp_path) -> None: - monkeypatch.setattr("nanobot.config.loader.get_data_dir", lambda: tmp_path) - - result = runner.invoke( - app, - [ - "cron", - "add", - "--name", - "demo", - "--message", - "hello", - "--cron", - "0 9 * * *", - "--tz", - "America/Vancovuer", - ], - ) - - assert result.exit_code == 1 - assert "Error: unknown timezone 'America/Vancovuer'" in result.stdout - assert not (tmp_path / "cron" / "jobs.json").exists() diff --git a/tests/test_cron_service.py b/tests/test_cron_service.py index 2a36f4c..9631da5 100644 --- a/tests/test_cron_service.py +++ b/tests/test_cron_service.py @@ -48,6 +48,8 @@ async def test_running_service_honors_external_disable(tmp_path) -> None: ) await service.start() try: + # Wait slightly to ensure file mtime is definitively different + await asyncio.sleep(0.05) external = CronService(store_path) updated = external.enable_job(job.id, enabled=False) assert updated is not None diff --git a/tests/test_matrix_channel.py b/tests/test_matrix_channel.py index c6714c2..c25b95a 100644 --- a/tests/test_matrix_channel.py +++ b/tests/test_matrix_channel.py @@ -159,6 +159,7 @@ class _FakeAsyncClient: def _make_config(**kwargs) -> MatrixConfig: + kwargs.setdefault("allow_from", ["*"]) return MatrixConfig( enabled=True, homeserver="https://matrix.org", @@ -274,7 +275,7 @@ async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None: @pytest.mark.asyncio -async def test_room_invite_joins_when_allow_list_is_empty() -> None: +async def test_room_invite_ignores_when_allow_list_is_empty() -> None: channel = MatrixChannel(_make_config(allow_from=[]), MessageBus()) client = _FakeAsyncClient("", "", "", None) channel.client = client @@ -284,9 +285,22 @@ async def test_room_invite_joins_when_allow_list_is_empty() -> None: await channel._on_room_invite(room, event) - assert client.join_calls == ["!room:matrix.org"] + assert client.join_calls == [] +@pytest.mark.asyncio +async def test_room_invite_joins_when_sender_allowed() -> None: + channel = MatrixChannel(_make_config(allow_from=["@alice:matrix.org"]), MessageBus()) + client = _FakeAsyncClient("", "", "", None) + channel.client = client + + room = SimpleNamespace(room_id="!room:matrix.org") + event = SimpleNamespace(sender="@alice:matrix.org") + + await channel._on_room_invite(room, event) + + assert client.join_calls == ["!room:matrix.org"] + @pytest.mark.asyncio async def test_room_invite_respects_allow_list_when_configured() -> None: channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus()) @@ -1163,6 +1177,8 @@ async def test_send_progress_keeps_typing_keepalive_running() -> None: assert "!room:matrix.org" in channel._typing_tasks assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS) + await channel.stop() + @pytest.mark.asyncio async def test_send_clears_typing_when_send_fails() -> None: