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 f141a1c..6c9304d 100644
--- a/README.md
+++ b/README.md
@@ -353,7 +353,7 @@ pip install nanobot-ai[matrix]
"accessToken": "syt_xxx",
"deviceId": "NANOBOT01",
"e2eeEnabled": true,
- "allowFrom": [],
+ "allowFrom": ["@your_user:matrix.org"],
"groupPolicy": "open",
"groupAllowFrom": [],
"allowRoomMentions": false,
@@ -447,14 +447,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**
@@ -484,7 +484,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
@@ -494,7 +494,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"]
}
}
}
@@ -533,13 +533,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**
@@ -574,6 +574,7 @@ Uses **Socket Mode** — no public URL required.
"enabled": true,
"botToken": "xoxb-...",
"appToken": "xapp-...",
+ "allowFrom": ["YOUR_SLACK_USER_ID"],
"groupPolicy": "mention"
}
}
@@ -607,7 +608,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.
@@ -880,6 +881,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 |
|--------|---------|-------------|
@@ -905,23 +907,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`.
-
-Scheduled Tasks (Cron)
-
-```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
-```
-
-
-
Heartbeat (Periodic Tasks)
diff --git a/SECURITY.md b/SECURITY.md
index 405ce52..af4da71 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -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)
diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py
index 010b126..df4825f 100644
--- a/nanobot/agent/context.py
+++ b/nanobot/agent/context.py
@@ -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]]:
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 488615d..65a62e5 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -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()
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 fe1dce6..f8e737b 100644
--- a/nanobot/agent/tools/cron.py
+++ b/nanobot/agent/tools/cron.py
@@ -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
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/base.py b/nanobot/channels/base.py
index f795931..b38fcaf 100644
--- a/nanobot/channels/base.py
+++ b/nanobot/channels/base.py
@@ -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,
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/manager.py b/nanobot/channels/manager.py
index 4b40d0e..7d7d110 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -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:
diff --git a/nanobot/channels/matrix.py b/nanobot/channels/matrix.py
index 43fc573..4967ac1 100644
--- a/nanobot/channels/matrix.py
+++ b/nanobot/channels/matrix.py
@@ -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(
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/cli/commands.py b/nanobot/cli/commands.py
index 2662e9f..b75a2bc 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -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
# ============================================================================
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index e3d3d23..a6b609b 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -172,6 +172,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)
diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py
index c3864ae..1ed71f0 100644
--- a/nanobot/cron/service.py
+++ b/nanobot/cron/service.py
@@ -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
diff --git a/nanobot/providers/base.py b/nanobot/providers/base.py
index af23a4c..55bd805 100644
--- a/nanobot/providers/base.py
+++ b/nanobot/providers/base.py
@@ -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
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/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 79277bc..d8d8ace 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -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,
}
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/nanobot/templates/AGENTS.md b/nanobot/templates/AGENTS.md
index 4c3e5b1..a24604b 100644
--- a/nanobot/templates/AGENTS.md
+++ b/nanobot/templates/AGENTS.md
@@ -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
diff --git a/pyproject.toml b/pyproject.toml
index a22053c..d86bb28 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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]
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 07e990a..9631da5 100644
--- a/tests/test_cron_service.py
+++ b/tests/test_cron_service.py
@@ -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()
diff --git a/tests/test_loop_save_turn.py b/tests/test_loop_save_turn.py
new file mode 100644
index 0000000..aec6d1a
--- /dev/null
+++ b/tests/test_loop_save_turn.py
@@ -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]"}]
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: