Merge branch 'main' into pr-1428
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,4 +19,4 @@ __pycache__/
|
||||
poetry.lock
|
||||
.pytest_cache/
|
||||
botpy.log
|
||||
tests/
|
||||
|
||||
|
||||
35
README.md
35
README.md
@@ -347,7 +347,7 @@ pip install nanobot-ai[matrix]
|
||||
"accessToken": "syt_xxx",
|
||||
"deviceId": "NANOBOT01",
|
||||
"e2eeEnabled": true,
|
||||
"allowFrom": [],
|
||||
"allowFrom": ["@your_user:matrix.org"],
|
||||
"groupPolicy": "open",
|
||||
"groupAllowFrom": [],
|
||||
"allowRoomMentions": false,
|
||||
@@ -441,14 +441,14 @@ Uses **WebSocket** long connection — no public IP required.
|
||||
"appSecret": "xxx",
|
||||
"encryptKey": "",
|
||||
"verificationToken": "",
|
||||
"allowFrom": []
|
||||
"allowFrom": ["ou_YOUR_OPEN_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `encryptKey` and `verificationToken` are optional for Long Connection mode.
|
||||
> `allowFrom`: Leave empty to allow all users, or add `["ou_xxx"]` to restrict access.
|
||||
> `allowFrom`: Add your open_id (find it in nanobot logs when you message the bot). Use `["*"]` to allow all users.
|
||||
|
||||
**3. Run**
|
||||
|
||||
@@ -478,7 +478,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
||||
|
||||
**3. Configure**
|
||||
|
||||
> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot.
|
||||
> - `allowFrom`: Add your openid (find it in nanobot logs when you message the bot). Use `["*"]` for public access.
|
||||
> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
|
||||
|
||||
```json
|
||||
@@ -488,7 +488,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
||||
"enabled": true,
|
||||
"appId": "YOUR_APP_ID",
|
||||
"secret": "YOUR_APP_SECRET",
|
||||
"allowFrom": []
|
||||
"allowFrom": ["YOUR_OPENID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,13 +527,13 @@ Uses **Stream Mode** — no public IP required.
|
||||
"enabled": true,
|
||||
"clientId": "YOUR_APP_KEY",
|
||||
"clientSecret": "YOUR_APP_SECRET",
|
||||
"allowFrom": []
|
||||
"allowFrom": ["YOUR_STAFF_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allowFrom`: Leave empty to allow all users, or add `["staffId"]` to restrict access.
|
||||
> `allowFrom`: Add your staff ID. Use `["*"]` to allow all users.
|
||||
|
||||
**3. Run**
|
||||
|
||||
@@ -568,6 +568,7 @@ Uses **Socket Mode** — no public URL required.
|
||||
"enabled": true,
|
||||
"botToken": "xoxb-...",
|
||||
"appToken": "xapp-...",
|
||||
"allowFrom": ["YOUR_SLACK_USER_ID"],
|
||||
"groupPolicy": "mention"
|
||||
}
|
||||
}
|
||||
@@ -601,7 +602,7 @@ Give nanobot its own email account. It polls **IMAP** for incoming mail and repl
|
||||
**2. Configure**
|
||||
|
||||
> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate — set `false` to fully disable.
|
||||
> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders.
|
||||
> - `allowFrom`: Add your email address. Use `["*"]` to accept emails from anyone.
|
||||
> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
|
||||
> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
|
||||
|
||||
@@ -874,6 +875,7 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
|
||||
> [!TIP]
|
||||
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
||||
> **Change in source / post-`v0.1.4.post3`:** In `v0.1.4.post3` and earlier, an empty `allowFrom` means "allow all senders". In newer versions (including building from source), **empty `allowFrom` denies all access by default**. To allow all senders, set `"allowFrom": ["*"]`.
|
||||
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
@@ -899,23 +901,6 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
||||
|
||||
Interactive mode exits: `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
||||
|
||||
<details>
|
||||
<summary><b>Scheduled Tasks (Cron)</b></summary>
|
||||
|
||||
```bash
|
||||
# Add a job
|
||||
nanobot cron add --name "daily" --message "Good morning!" --cron "0 9 * * *"
|
||||
nanobot cron add --name "hourly" --message "Check status" --every 3600
|
||||
|
||||
# List jobs
|
||||
nanobot cron list
|
||||
|
||||
# Remove a job
|
||||
nanobot cron remove <job_id>
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json
|
||||
```
|
||||
|
||||
**Security Notes:**
|
||||
- Empty `allowFrom` list will **ALLOW ALL** users (open by default for personal use)
|
||||
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allows all users. In newer versions (including source builds), **empty `allowFrom` denies all access** — set `["*"]` to explicitly allow everyone.
|
||||
- Get your Telegram user ID from `@userinfobot`
|
||||
- Use full phone numbers with country code for WhatsApp
|
||||
- Review access logs regularly for unauthorized access attempts
|
||||
@@ -212,9 +212,8 @@ If you suspect a security breach:
|
||||
- Input length limits on HTTP requests
|
||||
|
||||
✅ **Authentication**
|
||||
- Allow-list based access control
|
||||
- Allow-list based access control — in `v0.1.4.post3` and earlier empty means allow all; in newer versions empty means deny all (`["*"]` to explicitly allow all)
|
||||
- Failed authentication attempt logging
|
||||
- Open by default (configure allowFrom for production use)
|
||||
|
||||
✅ **Resource Protection**
|
||||
- Command execution timeouts (60s default)
|
||||
|
||||
@@ -112,11 +112,20 @@ Reply directly with text for conversations. Only use the 'message' tool to send
|
||||
chat_id: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Build the complete message list for an LLM call."""
|
||||
runtime_ctx = self._build_runtime_context(channel, chat_id)
|
||||
user_content = self._build_user_content(current_message, media)
|
||||
|
||||
# Merge runtime context and user content into a single user message
|
||||
# to avoid consecutive same-role messages that some providers reject.
|
||||
if isinstance(user_content, str):
|
||||
merged = f"{runtime_ctx}\n\n{user_content}"
|
||||
else:
|
||||
merged = [{"type": "text", "text": runtime_ctx}] + user_content
|
||||
|
||||
return [
|
||||
{"role": "system", "content": self.build_system_prompt(skill_names)},
|
||||
*history,
|
||||
{"role": "user", "content": self._build_runtime_context(channel, chat_id)},
|
||||
{"role": "user", "content": self._build_user_content(current_message, media)},
|
||||
{"role": "user", "content": merged},
|
||||
]
|
||||
|
||||
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
||||
|
||||
@@ -464,14 +464,25 @@ class AgentLoop:
|
||||
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
|
||||
elif role == "user":
|
||||
if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
|
||||
continue
|
||||
# Strip the runtime-context prefix, keep only the user text.
|
||||
parts = content.split("\n\n", 1)
|
||||
if len(parts) > 1 and parts[1].strip():
|
||||
entry["content"] = parts[1]
|
||||
else:
|
||||
continue
|
||||
if isinstance(content, list):
|
||||
entry["content"] = [
|
||||
{"type": "text", "text": "[image]"} if (
|
||||
c.get("type") == "image_url"
|
||||
and c.get("image_url", {}).get("url", "").startswith("data:image/")
|
||||
) else c for c in content
|
||||
]
|
||||
filtered = []
|
||||
for c in content:
|
||||
if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
|
||||
continue # Strip runtime context from multimodal messages
|
||||
if (c.get("type") == "image_url"
|
||||
and c.get("image_url", {}).get("url", "").startswith("data:image/")):
|
||||
filtered.append({"type": "text", "text": "[image]"})
|
||||
else:
|
||||
filtered.append(c)
|
||||
if not filtered:
|
||||
continue
|
||||
entry["content"] = filtered
|
||||
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||
session.messages.append(entry)
|
||||
session.updated_at = datetime.now()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Cron tool for scheduling reminders and tasks."""
|
||||
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from nanobot.agent.tools.base import Tool
|
||||
@@ -14,12 +15,21 @@ class CronTool(Tool):
|
||||
self._cron = cron_service
|
||||
self._channel = ""
|
||||
self._chat_id = ""
|
||||
self._in_cron_context: ContextVar[bool] = ContextVar("cron_in_context", default=False)
|
||||
|
||||
def set_context(self, channel: str, chat_id: str) -> None:
|
||||
"""Set the current session context for delivery."""
|
||||
self._channel = channel
|
||||
self._chat_id = chat_id
|
||||
|
||||
def set_cron_context(self, active: bool):
|
||||
"""Mark whether the tool is executing inside a cron job callback."""
|
||||
return self._in_cron_context.set(active)
|
||||
|
||||
def reset_cron_context(self, token) -> None:
|
||||
"""Restore previous cron context."""
|
||||
self._in_cron_context.reset(token)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cron"
|
||||
@@ -72,6 +82,8 @@ class CronTool(Tool):
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
if action == "add":
|
||||
if self._in_cron_context.get():
|
||||
return "Error: cannot schedule new jobs from within a cron job execution"
|
||||
return self._add_job(message, every_seconds, cron_expr, tz, at)
|
||||
elif action == "list":
|
||||
return self._list_jobs()
|
||||
@@ -110,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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -59,29 +59,17 @@ class BaseChannel(ABC):
|
||||
pass
|
||||
|
||||
def is_allowed(self, sender_id: str) -> bool:
|
||||
"""
|
||||
Check if a sender is allowed to use this bot.
|
||||
|
||||
Args:
|
||||
sender_id: The sender's identifier.
|
||||
|
||||
Returns:
|
||||
True if allowed, False otherwise.
|
||||
"""
|
||||
"""Check if *sender_id* is permitted. Empty list → deny all; ``"*"`` → allow all."""
|
||||
allow_list = getattr(self.config, "allow_from", [])
|
||||
|
||||
# If no allow list, allow everyone
|
||||
if not allow_list:
|
||||
logger.warning("{}: allow_from is empty — all access denied", self.name)
|
||||
return False
|
||||
if "*" in allow_list:
|
||||
return True
|
||||
|
||||
sender_str = str(sender_id)
|
||||
if sender_str in allow_list:
|
||||
return True
|
||||
if "|" in sender_str:
|
||||
for part in sender_str.split("|"):
|
||||
if part and part in allow_list:
|
||||
return True
|
||||
return False
|
||||
return sender_str in allow_list or any(
|
||||
p in allow_list for p in sender_str.split("|") if p
|
||||
)
|
||||
|
||||
async def _handle_message(
|
||||
self,
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -149,6 +149,16 @@ class ChannelManager:
|
||||
except ImportError as e:
|
||||
logger.warning("Matrix channel not available: {}", e)
|
||||
|
||||
self._validate_allow_from()
|
||||
|
||||
def _validate_allow_from(self) -> None:
|
||||
for name, ch in self.channels.items():
|
||||
if getattr(ch.config, "allow_from", None) == []:
|
||||
raise SystemExit(
|
||||
f'Error: "{name}" has empty allowFrom (denies all). '
|
||||
f'Set ["*"] to allow everyone, or add specific user IDs.'
|
||||
)
|
||||
|
||||
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||
"""Start a channel and log any exceptions."""
|
||||
try:
|
||||
|
||||
@@ -362,7 +362,11 @@ class MatrixChannel(BaseChannel):
|
||||
limit_bytes = await self._effective_media_limit_bytes()
|
||||
for path in candidates:
|
||||
if fail := await self._upload_and_send_attachment(
|
||||
msg.chat_id, path, limit_bytes, relates_to):
|
||||
room_id=msg.chat_id,
|
||||
path=path,
|
||||
limit_bytes=limit_bytes,
|
||||
relates_to=relates_to,
|
||||
):
|
||||
failures.append(fail)
|
||||
if failures:
|
||||
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(failures)
|
||||
@@ -450,8 +454,7 @@ class MatrixChannel(BaseChannel):
|
||||
await asyncio.sleep(2)
|
||||
|
||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||
allow_from = self.config.allow_from or []
|
||||
if not allow_from or event.sender in allow_from:
|
||||
if self.is_allowed(event.sender):
|
||||
await self.client.join(room.room_id)
|
||||
|
||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||
@@ -676,11 +679,13 @@ class MatrixChannel(BaseChannel):
|
||||
parts: list[str] = []
|
||||
if isinstance(body := getattr(event, "body", None), str) and body.strip():
|
||||
parts.append(body.strip())
|
||||
parts.append(marker)
|
||||
if marker:
|
||||
parts.append(marker)
|
||||
|
||||
await self._start_typing_keepalive(room.room_id)
|
||||
try:
|
||||
meta = self._base_metadata(room, event)
|
||||
meta["attachments"] = []
|
||||
if attachment:
|
||||
meta["attachments"] = [attachment]
|
||||
await self._handle_message(
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -296,6 +296,7 @@ def gateway(
|
||||
# Set cron callback (needs agent)
|
||||
async def on_cron_job(job: CronJob) -> str | None:
|
||||
"""Execute a cron job through the agent."""
|
||||
from nanobot.agent.tools.cron import CronTool
|
||||
from nanobot.agent.tools.message import MessageTool
|
||||
reminder_note = (
|
||||
"[Scheduled Task] Timer finished.\n\n"
|
||||
@@ -303,12 +304,21 @@ def gateway(
|
||||
f"Scheduled instruction: {job.payload.message}"
|
||||
)
|
||||
|
||||
response = await agent.process_direct(
|
||||
reminder_note,
|
||||
session_key=f"cron:{job.id}",
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to or "direct",
|
||||
)
|
||||
# Prevent the agent from scheduling new cron jobs during execution
|
||||
cron_tool = agent.tools.get("cron")
|
||||
cron_token = None
|
||||
if isinstance(cron_tool, CronTool):
|
||||
cron_token = cron_tool.set_cron_context(True)
|
||||
try:
|
||||
response = await agent.process_direct(
|
||||
reminder_note,
|
||||
session_key=f"cron:{job.id}",
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to or "direct",
|
||||
)
|
||||
finally:
|
||||
if isinstance(cron_tool, CronTool) and cron_token is not None:
|
||||
cron_tool.reset_cron_context(cron_token)
|
||||
|
||||
message_tool = agent.tools.get("message")
|
||||
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
||||
@@ -772,221 +782,6 @@ def channels_login():
|
||||
console.print("[red]npm not found. Please install Node.js.[/red]")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Cron Commands
|
||||
# ============================================================================
|
||||
|
||||
cron_app = typer.Typer(help="Manage scheduled tasks")
|
||||
app.add_typer(cron_app, name="cron")
|
||||
|
||||
|
||||
@cron_app.command("list")
|
||||
def cron_list(
|
||||
all: bool = typer.Option(False, "--all", "-a", help="Include disabled jobs"),
|
||||
):
|
||||
"""List scheduled jobs."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
jobs = service.list_jobs(include_disabled=all)
|
||||
|
||||
if not jobs:
|
||||
console.print("No scheduled jobs.")
|
||||
return
|
||||
|
||||
table = Table(title="Scheduled Jobs")
|
||||
table.add_column("ID", style="cyan")
|
||||
table.add_column("Name")
|
||||
table.add_column("Schedule")
|
||||
table.add_column("Status")
|
||||
table.add_column("Next Run")
|
||||
|
||||
import time
|
||||
from datetime import datetime as _dt
|
||||
from zoneinfo import ZoneInfo
|
||||
for job in jobs:
|
||||
# Format schedule
|
||||
if job.schedule.kind == "every":
|
||||
sched = f"every {(job.schedule.every_ms or 0) // 1000}s"
|
||||
elif job.schedule.kind == "cron":
|
||||
sched = f"{job.schedule.expr or ''} ({job.schedule.tz})" if job.schedule.tz else (job.schedule.expr or "")
|
||||
else:
|
||||
sched = "one-time"
|
||||
|
||||
# Format next run
|
||||
next_run = ""
|
||||
if job.state.next_run_at_ms:
|
||||
ts = job.state.next_run_at_ms / 1000
|
||||
try:
|
||||
tz = ZoneInfo(job.schedule.tz) if job.schedule.tz else None
|
||||
next_run = _dt.fromtimestamp(ts, tz).strftime("%Y-%m-%d %H:%M")
|
||||
except Exception:
|
||||
next_run = time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
||||
|
||||
status = "[green]enabled[/green]" if job.enabled else "[dim]disabled[/dim]"
|
||||
|
||||
table.add_row(job.id, job.name, sched, status, next_run)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
@cron_app.command("add")
|
||||
def cron_add(
|
||||
name: str = typer.Option(..., "--name", "-n", help="Job name"),
|
||||
message: str = typer.Option(..., "--message", "-m", help="Message for agent"),
|
||||
every: int = typer.Option(None, "--every", "-e", help="Run every N seconds"),
|
||||
cron_expr: str = typer.Option(None, "--cron", "-c", help="Cron expression (e.g. '0 9 * * *')"),
|
||||
tz: str | None = typer.Option(None, "--tz", help="IANA timezone for cron (e.g. 'America/Vancouver')"),
|
||||
at: str = typer.Option(None, "--at", help="Run once at time (ISO format)"),
|
||||
deliver: bool = typer.Option(False, "--deliver", "-d", help="Deliver response to channel"),
|
||||
to: str = typer.Option(None, "--to", help="Recipient for delivery"),
|
||||
channel: str = typer.Option(None, "--channel", help="Channel for delivery (e.g. 'telegram', 'whatsapp')"),
|
||||
):
|
||||
"""Add a scheduled job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronSchedule
|
||||
|
||||
if tz and not cron_expr:
|
||||
console.print("[red]Error: --tz can only be used with --cron[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Determine schedule type
|
||||
if every:
|
||||
schedule = CronSchedule(kind="every", every_ms=every * 1000)
|
||||
elif cron_expr:
|
||||
schedule = CronSchedule(kind="cron", expr=cron_expr, tz=tz)
|
||||
elif at:
|
||||
import datetime
|
||||
dt = datetime.datetime.fromisoformat(at)
|
||||
schedule = CronSchedule(kind="at", at_ms=int(dt.timestamp() * 1000))
|
||||
else:
|
||||
console.print("[red]Error: Must specify --every, --cron, or --at[/red]")
|
||||
raise typer.Exit(1)
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
try:
|
||||
job = service.add_job(
|
||||
name=name,
|
||||
schedule=schedule,
|
||||
message=message,
|
||||
deliver=deliver,
|
||||
to=to,
|
||||
channel=channel,
|
||||
)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
raise typer.Exit(1) from e
|
||||
|
||||
console.print(f"[green]✓[/green] Added job '{job.name}' ({job.id})")
|
||||
|
||||
|
||||
@cron_app.command("remove")
|
||||
def cron_remove(
|
||||
job_id: str = typer.Argument(..., help="Job ID to remove"),
|
||||
):
|
||||
"""Remove a scheduled job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
if service.remove_job(job_id):
|
||||
console.print(f"[green]✓[/green] Removed job {job_id}")
|
||||
else:
|
||||
console.print(f"[red]Job {job_id} not found[/red]")
|
||||
|
||||
|
||||
@cron_app.command("enable")
|
||||
def cron_enable(
|
||||
job_id: str = typer.Argument(..., help="Job ID"),
|
||||
disable: bool = typer.Option(False, "--disable", help="Disable instead of enable"),
|
||||
):
|
||||
"""Enable or disable a job."""
|
||||
from nanobot.config.loader import get_data_dir
|
||||
from nanobot.cron.service import CronService
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
job = service.enable_job(job_id, enabled=not disable)
|
||||
if job:
|
||||
status = "disabled" if disable else "enabled"
|
||||
console.print(f"[green]✓[/green] Job '{job.name}' {status}")
|
||||
else:
|
||||
console.print(f"[red]Job {job_id} not found[/red]")
|
||||
|
||||
|
||||
@cron_app.command("run")
|
||||
def cron_run(
|
||||
job_id: str = typer.Argument(..., help="Job ID to run"),
|
||||
force: bool = typer.Option(False, "--force", "-f", help="Run even if disabled"),
|
||||
):
|
||||
"""Manually run a job."""
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.loader import get_data_dir, load_config
|
||||
from nanobot.cron.service import CronService
|
||||
from nanobot.cron.types import CronJob
|
||||
logger.disable("nanobot")
|
||||
|
||||
config = load_config()
|
||||
provider = _make_provider(config)
|
||||
bus = MessageBus()
|
||||
agent_loop = AgentLoop(
|
||||
bus=bus,
|
||||
provider=provider,
|
||||
workspace=config.workspace_path,
|
||||
model=config.agents.defaults.model,
|
||||
temperature=config.agents.defaults.temperature,
|
||||
max_tokens=config.agents.defaults.max_tokens,
|
||||
max_iterations=config.agents.defaults.max_tool_iterations,
|
||||
memory_window=config.agents.defaults.memory_window,
|
||||
reasoning_effort=config.agents.defaults.reasoning_effort,
|
||||
brave_api_key=config.tools.web.search.api_key or None,
|
||||
web_proxy=config.tools.web.proxy or None,
|
||||
exec_config=config.tools.exec,
|
||||
restrict_to_workspace=config.tools.restrict_to_workspace,
|
||||
mcp_servers=config.tools.mcp_servers,
|
||||
channels_config=config.channels,
|
||||
)
|
||||
|
||||
store_path = get_data_dir() / "cron" / "jobs.json"
|
||||
service = CronService(store_path)
|
||||
|
||||
result_holder = []
|
||||
|
||||
async def on_job(job: CronJob) -> str | None:
|
||||
response = await agent_loop.process_direct(
|
||||
job.payload.message,
|
||||
session_key=f"cron:{job.id}",
|
||||
channel=job.payload.channel or "cli",
|
||||
chat_id=job.payload.to or "direct",
|
||||
)
|
||||
result_holder.append(response)
|
||||
return response
|
||||
|
||||
service.on_job = on_job
|
||||
|
||||
async def run():
|
||||
return await service.run_job(job_id, force=force)
|
||||
|
||||
if asyncio.run(run()):
|
||||
console.print("[green]✓[/green] Job executed")
|
||||
if result_holder:
|
||||
_print_agent_response(result_holder[0], render_markdown=True)
|
||||
else:
|
||||
console.print(f"[red]Failed to run job {job_id}[/red]")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Status Commands
|
||||
# ============================================================================
|
||||
|
||||
@@ -171,6 +171,7 @@ class SlackConfig(Base):
|
||||
user_token_read_only: bool = True
|
||||
reply_in_thread: bool = True
|
||||
react_emoji: str = "eyes"
|
||||
allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs (sender-level)
|
||||
group_policy: str = "mention" # "mention", "open", "allowlist"
|
||||
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||
|
||||
@@ -226,6 +226,7 @@ class CronService:
|
||||
|
||||
async def _on_timer(self) -> None:
|
||||
"""Handle timer tick - run due jobs."""
|
||||
self._load_store()
|
||||
if not self._store:
|
||||
return
|
||||
|
||||
|
||||
@@ -78,6 +78,12 @@ class LLMProvider(ABC):
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
if isinstance(content, dict):
|
||||
clean = dict(msg)
|
||||
clean["content"] = [content]
|
||||
result.append(clean)
|
||||
continue
|
||||
|
||||
result.append(msg)
|
||||
return result
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ from litellm import acompletion
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.providers.registry import find_by_model, find_gateway
|
||||
|
||||
# Standard OpenAI chat-completion message keys plus reasoning_content for
|
||||
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
|
||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"})
|
||||
# Standard chat-completion message keys.
|
||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
||||
_ANTHROPIC_EXTRA_KEYS = frozenset({"thinking_blocks"})
|
||||
_ALNUM = string.ascii_letters + string.digits
|
||||
|
||||
def _short_tool_id() -> str:
|
||||
@@ -158,11 +158,20 @@ class LiteLLMProvider(LLMProvider):
|
||||
return
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _extra_msg_keys(original_model: str, resolved_model: str) -> frozenset[str]:
|
||||
"""Return provider-specific extra keys to preserve in request messages."""
|
||||
spec = find_by_model(original_model) or find_by_model(resolved_model)
|
||||
if (spec and spec.name == "anthropic") or "claude" in original_model.lower() or resolved_model.startswith("anthropic/"):
|
||||
return _ANTHROPIC_EXTRA_KEYS
|
||||
return frozenset()
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_messages(messages: list[dict[str, Any]], extra_keys: frozenset[str] = frozenset()) -> list[dict[str, Any]]:
|
||||
"""Strip non-standard keys and ensure assistant messages have a content key."""
|
||||
allowed = _ALLOWED_MSG_KEYS | extra_keys
|
||||
sanitized = []
|
||||
for msg in messages:
|
||||
clean = {k: v for k, v in msg.items() if k in _ALLOWED_MSG_KEYS}
|
||||
clean = {k: v for k, v in msg.items() if k in allowed}
|
||||
# Strict providers require "content" even when assistant only has tool_calls
|
||||
if clean.get("role") == "assistant" and "content" not in clean:
|
||||
clean["content"] = None
|
||||
@@ -193,6 +202,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
"""
|
||||
original_model = model or self.default_model
|
||||
model = self._resolve_model(original_model)
|
||||
extra_msg_keys = self._extra_msg_keys(original_model, model)
|
||||
|
||||
if self._supports_cache_control(original_model):
|
||||
messages, tools = self._apply_cache_control(messages, tools)
|
||||
@@ -203,7 +213,7 @@ class LiteLLMProvider(LLMProvider):
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages)),
|
||||
"messages": self._sanitize_messages(self._sanitize_empty_content(messages), extra_keys=extra_msg_keys),
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -4,17 +4,15 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
## Scheduled Reminders
|
||||
|
||||
When user asks for a reminder at a specific time, use `exec` to run:
|
||||
```
|
||||
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
|
||||
```
|
||||
Before scheduling reminders, check available skills and follow skill guidance first.
|
||||
Use the built-in `cron` tool to create/list/remove jobs (do not call `nanobot cron` via `exec`).
|
||||
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
||||
|
||||
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||
|
||||
## Heartbeat Tasks
|
||||
|
||||
`HEARTBEAT.md` is checked every 30 minutes. Use file tools to manage periodic tasks:
|
||||
`HEARTBEAT.md` is checked on the configured heartbeat interval. Use file tools to manage periodic tasks:
|
||||
|
||||
- **Add**: `edit_file` to append new tasks
|
||||
- **Remove**: `edit_file` to delete completed tasks
|
||||
|
||||
@@ -42,6 +42,8 @@ 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",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -54,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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -1,3 +1,5 @@
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.cron.service import CronService
|
||||
@@ -28,3 +30,32 @@ def test_add_job_accepts_valid_timezone(tmp_path) -> None:
|
||||
|
||||
assert job.schedule.tz == "America/Vancouver"
|
||||
assert job.state.next_run_at_ms is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_running_service_honors_external_disable(tmp_path) -> None:
|
||||
store_path = tmp_path / "cron" / "jobs.json"
|
||||
called: list[str] = []
|
||||
|
||||
async def on_job(job) -> None:
|
||||
called.append(job.id)
|
||||
|
||||
service = CronService(store_path, on_job=on_job)
|
||||
job = service.add_job(
|
||||
name="external-disable",
|
||||
schedule=CronSchedule(kind="every", every_ms=200),
|
||||
message="hello",
|
||||
)
|
||||
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
|
||||
assert updated.enabled is False
|
||||
|
||||
await asyncio.sleep(0.35)
|
||||
assert called == []
|
||||
finally:
|
||||
service.stop()
|
||||
|
||||
41
tests/test_loop_save_turn.py
Normal file
41
tests/test_loop_save_turn.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from nanobot.agent.context import ContextBuilder
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.session.manager import Session
|
||||
|
||||
|
||||
def _mk_loop() -> AgentLoop:
|
||||
loop = AgentLoop.__new__(AgentLoop)
|
||||
loop._TOOL_RESULT_MAX_CHARS = 500
|
||||
return loop
|
||||
|
||||
|
||||
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(key="test:runtime-only")
|
||||
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
|
||||
|
||||
loop._save_turn(
|
||||
session,
|
||||
[{"role": "user", "content": [{"type": "text", "text": runtime}]}],
|
||||
skip=0,
|
||||
)
|
||||
assert session.messages == []
|
||||
|
||||
|
||||
def test_save_turn_keeps_image_placeholder_after_runtime_strip() -> None:
|
||||
loop = _mk_loop()
|
||||
session = Session(key="test:image")
|
||||
runtime = ContextBuilder._RUNTIME_CONTEXT_TAG + "\nCurrent Time: now (UTC)"
|
||||
|
||||
loop._save_turn(
|
||||
session,
|
||||
[{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": runtime},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
|
||||
],
|
||||
}],
|
||||
skip=0,
|
||||
)
|
||||
assert session.messages[0]["content"] == [{"type": "text", "text": "[image]"}]
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user