Merge remote-tracking branch 'origin/main' into pr-1488
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,4 +19,4 @@ __pycache__/
|
|||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
botpy.log
|
botpy.log
|
||||||
tests/
|
|
||||||
|
|||||||
@@ -293,12 +293,18 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
|
|||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"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**
|
**5. Invite the bot**
|
||||||
- OAuth2 → URL Generator
|
- OAuth2 → URL Generator
|
||||||
- Scopes: `bot`
|
- Scopes: `bot`
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ class Tool(ABC):
|
|||||||
|
|
||||||
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
def validate_params(self, params: dict[str, Any]) -> list[str]:
|
||||||
"""Validate tool parameters against JSON schema. Returns error list (empty if valid)."""
|
"""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 {}
|
schema = self.parameters or {}
|
||||||
if schema.get("type", "object") != "object":
|
if schema.get("type", "object") != "object":
|
||||||
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
|
raise ValueError(f"Schema must be object type, got {schema.get('type')!r}")
|
||||||
|
|||||||
@@ -122,7 +122,10 @@ class CronTool(Tool):
|
|||||||
elif at:
|
elif at:
|
||||||
from datetime import datetime
|
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)
|
at_ms = int(dt.timestamp() * 1000)
|
||||||
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
schedule = CronSchedule(kind="at", at_ms=at_ms)
|
||||||
delete_after = True
|
delete_after = True
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ def _resolve_path(
|
|||||||
class ReadFileTool(Tool):
|
class ReadFileTool(Tool):
|
||||||
"""Tool to read file contents."""
|
"""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):
|
def __init__(self, workspace: Path | None = None, allowed_dir: Path | None = None):
|
||||||
self._workspace = workspace
|
self._workspace = workspace
|
||||||
self._allowed_dir = allowed_dir
|
self._allowed_dir = allowed_dir
|
||||||
@@ -54,7 +56,16 @@ class ReadFileTool(Tool):
|
|||||||
if not file_path.is_file():
|
if not file_path.is_file():
|
||||||
return f"Error: Not a file: {path}"
|
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")
|
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
|
return content
|
||||||
except PermissionError as e:
|
except PermissionError as e:
|
||||||
return f"Error: {e}"
|
return f"Error: {e}"
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
self._heartbeat_task: asyncio.Task | None = None
|
self._heartbeat_task: asyncio.Task | None = None
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
self._bot_user_id: str | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Discord gateway connection."""
|
"""Start the Discord gateway connection."""
|
||||||
@@ -170,6 +171,10 @@ class DiscordChannel(BaseChannel):
|
|||||||
await self._identify()
|
await self._identify()
|
||||||
elif op == 0 and event_type == "READY":
|
elif op == 0 and event_type == "READY":
|
||||||
logger.info("Discord gateway 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":
|
elif op == 0 and event_type == "MESSAGE_CREATE":
|
||||||
await self._handle_message_create(payload)
|
await self._handle_message_create(payload)
|
||||||
elif op == 7:
|
elif op == 7:
|
||||||
@@ -226,6 +231,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
sender_id = str(author.get("id", ""))
|
sender_id = str(author.get("id", ""))
|
||||||
channel_id = str(payload.get("channel_id", ""))
|
channel_id = str(payload.get("channel_id", ""))
|
||||||
content = payload.get("content") or ""
|
content = payload.get("content") or ""
|
||||||
|
guild_id = payload.get("guild_id")
|
||||||
|
|
||||||
if not sender_id or not channel_id:
|
if not sender_id or not channel_id:
|
||||||
return
|
return
|
||||||
@@ -233,6 +239,11 @@ class DiscordChannel(BaseChannel):
|
|||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
return
|
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 []
|
content_parts = [content] if content else []
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
media_dir = Path.home() / ".nanobot" / "media"
|
media_dir = Path.home() / ".nanobot" / "media"
|
||||||
@@ -269,11 +280,32 @@ class DiscordChannel(BaseChannel):
|
|||||||
media=media_paths,
|
media=media_paths,
|
||||||
metadata={
|
metadata={
|
||||||
"message_id": str(payload.get("id", "")),
|
"message_id": str(payload.get("id", "")),
|
||||||
"guild_id": payload.get("guild_id"),
|
"guild_id": guild_id,
|
||||||
"reply_to": reply_to,
|
"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:
|
async def _start_typing(self, channel_id: str) -> None:
|
||||||
"""Start periodic typing indicator for a channel."""
|
"""Start periodic typing indicator for a channel."""
|
||||||
await self._stop_typing(channel_id)
|
await self._stop_typing(channel_id)
|
||||||
|
|||||||
@@ -16,26 +16,9 @@ from nanobot.bus.queue import MessageBus
|
|||||||
from nanobot.channels.base import BaseChannel
|
from nanobot.channels.base import BaseChannel
|
||||||
from nanobot.config.schema import FeishuConfig
|
from nanobot.config.schema import FeishuConfig
|
||||||
|
|
||||||
try:
|
import importlib.util
|
||||||
import lark_oapi as lark
|
|
||||||
from lark_oapi.api.im.v1 import (
|
FEISHU_AVAILABLE = importlib.util.find_spec("lark_oapi") is not None
|
||||||
CreateFileRequest,
|
|
||||||
CreateFileRequestBody,
|
|
||||||
CreateImageRequest,
|
|
||||||
CreateImageRequestBody,
|
|
||||||
CreateMessageReactionRequest,
|
|
||||||
CreateMessageReactionRequestBody,
|
|
||||||
CreateMessageRequest,
|
|
||||||
CreateMessageRequestBody,
|
|
||||||
Emoji,
|
|
||||||
GetMessageResourceRequest,
|
|
||||||
P2ImMessageReceiveV1,
|
|
||||||
)
|
|
||||||
FEISHU_AVAILABLE = True
|
|
||||||
except ImportError:
|
|
||||||
FEISHU_AVAILABLE = False
|
|
||||||
lark = None
|
|
||||||
Emoji = None
|
|
||||||
|
|
||||||
# Message type display mapping
|
# Message type display mapping
|
||||||
MSG_TYPE_MAP = {
|
MSG_TYPE_MAP = {
|
||||||
@@ -280,6 +263,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
logger.error("Feishu app_id and app_secret not configured")
|
logger.error("Feishu app_id and app_secret not configured")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
import lark_oapi as lark
|
||||||
self._running = True
|
self._running = True
|
||||||
self._loop = asyncio.get_running_loop()
|
self._loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
@@ -306,16 +290,28 @@ class FeishuChannel(BaseChannel):
|
|||||||
log_level=lark.LogLevel.INFO
|
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():
|
def run_ws():
|
||||||
while self._running:
|
import time
|
||||||
try:
|
import lark_oapi.ws.client as _lark_ws_client
|
||||||
self._ws_client.start()
|
ws_loop = asyncio.new_event_loop()
|
||||||
except Exception as e:
|
asyncio.set_event_loop(ws_loop)
|
||||||
logger.warning("Feishu WebSocket error: {}", e)
|
# Patch the module-level loop used by lark's ws Client.start()
|
||||||
if self._running:
|
_lark_ws_client.loop = ws_loop
|
||||||
import time
|
try:
|
||||||
time.sleep(5)
|
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 = threading.Thread(target=run_ws, daemon=True)
|
||||||
self._ws_thread.start()
|
self._ws_thread.start()
|
||||||
@@ -340,6 +336,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
|
def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
|
||||||
"""Sync helper for adding reaction (runs in thread pool)."""
|
"""Sync helper for adding reaction (runs in thread pool)."""
|
||||||
|
from lark_oapi.api.im.v1 import CreateMessageReactionRequest, CreateMessageReactionRequestBody, Emoji
|
||||||
try:
|
try:
|
||||||
request = CreateMessageReactionRequest.builder() \
|
request = CreateMessageReactionRequest.builder() \
|
||||||
.message_id(message_id) \
|
.message_id(message_id) \
|
||||||
@@ -364,7 +361,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
|
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
|
||||||
"""
|
"""
|
||||||
if not self._client or not Emoji:
|
if not self._client:
|
||||||
return
|
return
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -456,6 +453,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
def _upload_image_sync(self, file_path: str) -> str | None:
|
def _upload_image_sync(self, file_path: str) -> str | None:
|
||||||
"""Upload an image to Feishu and return the image_key."""
|
"""Upload an image to Feishu and return the image_key."""
|
||||||
|
from lark_oapi.api.im.v1 import CreateImageRequest, CreateImageRequestBody
|
||||||
try:
|
try:
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
request = CreateImageRequest.builder() \
|
request = CreateImageRequest.builder() \
|
||||||
@@ -479,6 +477,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
def _upload_file_sync(self, file_path: str) -> str | None:
|
def _upload_file_sync(self, file_path: str) -> str | None:
|
||||||
"""Upload a file to Feishu and return the file_key."""
|
"""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()
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
|
file_type = self._FILE_TYPE_MAP.get(ext, "stream")
|
||||||
file_name = os.path.basename(file_path)
|
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]:
|
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."""
|
"""Download an image from Feishu message by message_id and image_key."""
|
||||||
|
from lark_oapi.api.im.v1 import GetMessageResourceRequest
|
||||||
try:
|
try:
|
||||||
request = GetMessageResourceRequest.builder() \
|
request = GetMessageResourceRequest.builder() \
|
||||||
.message_id(message_id) \
|
.message_id(message_id) \
|
||||||
@@ -530,6 +530,13 @@ class FeishuChannel(BaseChannel):
|
|||||||
self, message_id: str, file_key: str, resource_type: str = "file"
|
self, message_id: str, file_key: str, resource_type: str = "file"
|
||||||
) -> tuple[bytes | None, str | None]:
|
) -> tuple[bytes | None, str | None]:
|
||||||
"""Download a file/audio/media from a Feishu message by message_id and file_key."""
|
"""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:
|
try:
|
||||||
request = (
|
request = (
|
||||||
GetMessageResourceRequest.builder()
|
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:
|
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."""
|
"""Send a single message (text/image/file/interactive) synchronously."""
|
||||||
|
from lark_oapi.api.im.v1 import CreateMessageRequest, CreateMessageRequestBody
|
||||||
try:
|
try:
|
||||||
request = CreateMessageRequest.builder() \
|
request = CreateMessageRequest.builder() \
|
||||||
.receive_id_type(receive_id_type) \
|
.receive_id_type(receive_id_type) \
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ class QQChannel(BaseChannel):
|
|||||||
self.config: QQConfig = config
|
self.config: QQConfig = config
|
||||||
self._client: "botpy.Client | None" = None
|
self._client: "botpy.Client | None" = None
|
||||||
self._processed_ids: deque = deque(maxlen=1000)
|
self._processed_ids: deque = deque(maxlen=1000)
|
||||||
|
self._msg_seq: int = 1 # 消息序列号,避免被 QQ API 去重
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the QQ bot."""
|
"""Start the QQ bot."""
|
||||||
@@ -102,11 +103,13 @@ class QQChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
msg_id = msg.metadata.get("message_id")
|
msg_id = msg.metadata.get("message_id")
|
||||||
|
self._msg_seq += 1 # 递增序列号
|
||||||
await self._client.api.post_c2c_message(
|
await self._client.api.post_c2c_message(
|
||||||
openid=msg.chat_id,
|
openid=msg.chat_id,
|
||||||
msg_type=0,
|
msg_type=0,
|
||||||
content=msg.content,
|
content=msg.content,
|
||||||
msg_id=msg_id,
|
msg_id=msg_id,
|
||||||
|
msg_seq=self._msg_seq, # 添加序列号避免去重
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending QQ message: {}", e)
|
logger.error("Error sending QQ message: {}", e)
|
||||||
@@ -133,3 +136,4 @@ class QQChannel(BaseChannel):
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Error handling QQ message")
|
logger.exception("Error handling QQ message")
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ class DiscordConfig(Base):
|
|||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
||||||
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
||||||
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
||||||
|
group_policy: Literal["mention", "open"] = "mention"
|
||||||
|
|
||||||
|
|
||||||
class MatrixConfig(Base):
|
class MatrixConfig(Base):
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import json_repair
|
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"):
|
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)
|
super().__init__(api_key, api_base)
|
||||||
self.default_model = default_model
|
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,
|
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,
|
model: str | None = None, max_tokens: int = 4096, temperature: float = 0.7,
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ class OpenAICodexProvider(LLMProvider):
|
|||||||
"parallel_tool_calls": True,
|
"parallel_tool_calls": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if reasoning_effort:
|
||||||
|
body["reasoning"] = {"effort": reasoning_effort}
|
||||||
|
|
||||||
if tools:
|
if tools:
|
||||||
body["tools"] = _convert_tools(tools)
|
body["tools"] = _convert_tools(tools)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ dependencies = [
|
|||||||
"prompt-toolkit>=3.0.50,<4.0.0",
|
"prompt-toolkit>=3.0.50,<4.0.0",
|
||||||
"mcp>=1.26.0,<2.0.0",
|
"mcp>=1.26.0,<2.0.0",
|
||||||
"json-repair>=0.57.0,<1.0.0",
|
"json-repair>=0.57.0,<1.0.0",
|
||||||
|
"chardet>=3.0.2,<6.0.0",
|
||||||
"openai>=2.8.0",
|
"openai>=2.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -55,6 +56,9 @@ dev = [
|
|||||||
"pytest>=9.0.0,<10.0.0",
|
"pytest>=9.0.0,<10.0.0",
|
||||||
"pytest-asyncio>=1.3.0,<2.0.0",
|
"pytest-asyncio>=1.3.0,<2.0.0",
|
||||||
"ruff>=0.1.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]
|
[project.scripts]
|
||||||
|
|||||||
@@ -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:
|
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)
|
workspace = _make_workspace(tmp_path)
|
||||||
builder = ContextBuilder(workspace)
|
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 messages[0]["role"] == "system"
|
||||||
assert "## Current Session" not in messages[0]["content"]
|
assert "## Current Session" not in messages[0]["content"]
|
||||||
|
|
||||||
assert messages[-2]["role"] == "user"
|
# Runtime context is now merged with user message into a single message
|
||||||
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
|
|
||||||
|
|
||||||
assert messages[-1]["role"] == "user"
|
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
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -48,6 +48,8 @@ async def test_running_service_honors_external_disable(tmp_path) -> None:
|
|||||||
)
|
)
|
||||||
await service.start()
|
await service.start()
|
||||||
try:
|
try:
|
||||||
|
# Wait slightly to ensure file mtime is definitively different
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
external = CronService(store_path)
|
external = CronService(store_path)
|
||||||
updated = external.enable_job(job.id, enabled=False)
|
updated = external.enable_job(job.id, enabled=False)
|
||||||
assert updated is not None
|
assert updated is not None
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ class _FakeAsyncClient:
|
|||||||
|
|
||||||
|
|
||||||
def _make_config(**kwargs) -> MatrixConfig:
|
def _make_config(**kwargs) -> MatrixConfig:
|
||||||
|
kwargs.setdefault("allow_from", ["*"])
|
||||||
return MatrixConfig(
|
return MatrixConfig(
|
||||||
enabled=True,
|
enabled=True,
|
||||||
homeserver="https://matrix.org",
|
homeserver="https://matrix.org",
|
||||||
@@ -274,7 +275,7 @@ async def test_stop_stops_sync_forever_before_close(monkeypatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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())
|
channel = MatrixChannel(_make_config(allow_from=[]), MessageBus())
|
||||||
client = _FakeAsyncClient("", "", "", None)
|
client = _FakeAsyncClient("", "", "", None)
|
||||||
channel.client = client
|
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)
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
async def test_room_invite_respects_allow_list_when_configured() -> None:
|
||||||
channel = MatrixChannel(_make_config(allow_from=["@bob:matrix.org"]), MessageBus())
|
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 "!room:matrix.org" in channel._typing_tasks
|
||||||
assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)
|
assert client.typing_calls[-1] == ("!room:matrix.org", True, TYPING_NOTICE_TIMEOUT_MS)
|
||||||
|
|
||||||
|
await channel.stop()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_clears_typing_when_send_fails() -> None:
|
async def test_send_clears_typing_when_send_fails() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user