Merge remote-tracking branch 'origin/main' into pr-553
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/
|
|
||||||
|
|||||||
35
README.md
35
README.md
@@ -353,7 +353,7 @@ pip install nanobot-ai[matrix]
|
|||||||
"accessToken": "syt_xxx",
|
"accessToken": "syt_xxx",
|
||||||
"deviceId": "NANOBOT01",
|
"deviceId": "NANOBOT01",
|
||||||
"e2eeEnabled": true,
|
"e2eeEnabled": true,
|
||||||
"allowFrom": [],
|
"allowFrom": ["@your_user:matrix.org"],
|
||||||
"groupPolicy": "open",
|
"groupPolicy": "open",
|
||||||
"groupAllowFrom": [],
|
"groupAllowFrom": [],
|
||||||
"allowRoomMentions": false,
|
"allowRoomMentions": false,
|
||||||
@@ -447,14 +447,14 @@ Uses **WebSocket** long connection — no public IP required.
|
|||||||
"appSecret": "xxx",
|
"appSecret": "xxx",
|
||||||
"encryptKey": "",
|
"encryptKey": "",
|
||||||
"verificationToken": "",
|
"verificationToken": "",
|
||||||
"allowFrom": []
|
"allowFrom": ["ou_YOUR_OPEN_ID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> `encryptKey` and `verificationToken` are optional for Long Connection mode.
|
> `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**
|
**3. Run**
|
||||||
|
|
||||||
@@ -484,7 +484,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
|||||||
|
|
||||||
**3. Configure**
|
**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.
|
> - 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
|
```json
|
||||||
@@ -494,7 +494,7 @@ Uses **botpy SDK** with WebSocket — no public IP required. Currently supports
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"appId": "YOUR_APP_ID",
|
"appId": "YOUR_APP_ID",
|
||||||
"secret": "YOUR_APP_SECRET",
|
"secret": "YOUR_APP_SECRET",
|
||||||
"allowFrom": []
|
"allowFrom": ["YOUR_OPENID"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,13 +533,13 @@ Uses **Stream Mode** — no public IP required.
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"clientId": "YOUR_APP_KEY",
|
"clientId": "YOUR_APP_KEY",
|
||||||
"clientSecret": "YOUR_APP_SECRET",
|
"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**
|
**3. Run**
|
||||||
|
|
||||||
@@ -574,6 +574,7 @@ Uses **Socket Mode** — no public URL required.
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"botToken": "xoxb-...",
|
"botToken": "xoxb-...",
|
||||||
"appToken": "xapp-...",
|
"appToken": "xapp-...",
|
||||||
|
"allowFrom": ["YOUR_SLACK_USER_ID"],
|
||||||
"groupPolicy": "mention"
|
"groupPolicy": "mention"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -607,7 +608,7 @@ Give nanobot its own email account. It polls **IMAP** for incoming mail and repl
|
|||||||
**2. Configure**
|
**2. Configure**
|
||||||
|
|
||||||
> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate — set `false` to fully disable.
|
> - `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.
|
> - `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.
|
> - 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]
|
> [!TIP]
|
||||||
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
> 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 |
|
| 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`.
|
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>
|
<details>
|
||||||
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
<summary><b>Heartbeat (Periodic Tasks)</b></summary>
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ chmod 600 ~/.nanobot/config.json
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Security Notes:**
|
**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`
|
- Get your Telegram user ID from `@userinfobot`
|
||||||
- Use full phone numbers with country code for WhatsApp
|
- Use full phone numbers with country code for WhatsApp
|
||||||
- Review access logs regularly for unauthorized access attempts
|
- Review access logs regularly for unauthorized access attempts
|
||||||
@@ -212,9 +212,8 @@ If you suspect a security breach:
|
|||||||
- Input length limits on HTTP requests
|
- Input length limits on HTTP requests
|
||||||
|
|
||||||
✅ **Authentication**
|
✅ **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
|
- Failed authentication attempt logging
|
||||||
- Open by default (configure allowFrom for production use)
|
|
||||||
|
|
||||||
✅ **Resource Protection**
|
✅ **Resource Protection**
|
||||||
- Command execution timeouts (60s default)
|
- 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,
|
chat_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Build the complete message list for an LLM call."""
|
"""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 [
|
return [
|
||||||
{"role": "system", "content": self.build_system_prompt(skill_names)},
|
{"role": "system", "content": self.build_system_prompt(skill_names)},
|
||||||
*history,
|
*history,
|
||||||
{"role": "user", "content": self._build_runtime_context(channel, chat_id)},
|
{"role": "user", "content": merged},
|
||||||
{"role": "user", "content": self._build_user_content(current_message, media)},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]:
|
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)"
|
entry["content"] = content[:self._TOOL_RESULT_MAX_CHARS] + "\n... (truncated)"
|
||||||
elif role == "user":
|
elif role == "user":
|
||||||
if isinstance(content, str) and content.startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
|
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):
|
if isinstance(content, list):
|
||||||
entry["content"] = [
|
filtered = []
|
||||||
{"type": "text", "text": "[image]"} if (
|
for c in content:
|
||||||
c.get("type") == "image_url"
|
if c.get("type") == "text" and isinstance(c.get("text"), str) and c["text"].startswith(ContextBuilder._RUNTIME_CONTEXT_TAG):
|
||||||
and c.get("image_url", {}).get("url", "").startswith("data:image/")
|
continue # Strip runtime context from multimodal messages
|
||||||
) else c for c in content
|
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())
|
entry.setdefault("timestamp", datetime.now().isoformat())
|
||||||
session.messages.append(entry)
|
session.messages.append(entry)
|
||||||
session.updated_at = datetime.now()
|
session.updated_at = datetime.now()
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Cron tool for scheduling reminders and tasks."""
|
"""Cron tool for scheduling reminders and tasks."""
|
||||||
|
|
||||||
|
from contextvars import ContextVar
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from nanobot.agent.tools.base import Tool
|
from nanobot.agent.tools.base import Tool
|
||||||
@@ -14,12 +15,21 @@ class CronTool(Tool):
|
|||||||
self._cron = cron_service
|
self._cron = cron_service
|
||||||
self._channel = ""
|
self._channel = ""
|
||||||
self._chat_id = ""
|
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:
|
def set_context(self, channel: str, chat_id: str) -> None:
|
||||||
"""Set the current session context for delivery."""
|
"""Set the current session context for delivery."""
|
||||||
self._channel = channel
|
self._channel = channel
|
||||||
self._chat_id = chat_id
|
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
|
@property
|
||||||
def name(self) -> str:
|
def name(self) -> str:
|
||||||
return "cron"
|
return "cron"
|
||||||
@@ -72,6 +82,8 @@ class CronTool(Tool):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> str:
|
) -> str:
|
||||||
if action == "add":
|
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)
|
return self._add_job(message, every_seconds, cron_expr, tz, at)
|
||||||
elif action == "list":
|
elif action == "list":
|
||||||
return self._list_jobs()
|
return self._list_jobs()
|
||||||
@@ -110,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}"
|
||||||
|
|||||||
@@ -59,29 +59,17 @@ class BaseChannel(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def is_allowed(self, sender_id: str) -> bool:
|
def is_allowed(self, sender_id: str) -> bool:
|
||||||
"""
|
"""Check if *sender_id* is permitted. Empty list → deny all; ``"*"`` → allow all."""
|
||||||
Check if a sender is allowed to use this bot.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sender_id: The sender's identifier.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if allowed, False otherwise.
|
|
||||||
"""
|
|
||||||
allow_list = getattr(self.config, "allow_from", [])
|
allow_list = getattr(self.config, "allow_from", [])
|
||||||
|
|
||||||
# If no allow list, allow everyone
|
|
||||||
if not allow_list:
|
if not allow_list:
|
||||||
|
logger.warning("{}: allow_from is empty — all access denied", self.name)
|
||||||
|
return False
|
||||||
|
if "*" in allow_list:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
sender_str = str(sender_id)
|
sender_str = str(sender_id)
|
||||||
if sender_str in allow_list:
|
return sender_str in allow_list or any(
|
||||||
return True
|
p in allow_list for p in sender_str.split("|") if p
|
||||||
if "|" in sender_str:
|
)
|
||||||
for part in sender_str.split("|"):
|
|
||||||
if part and part in allow_list:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _handle_message(
|
async def _handle_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -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) \
|
||||||
|
|||||||
@@ -149,6 +149,16 @@ class ChannelManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning("Matrix channel not available: {}", 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:
|
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||||
"""Start a channel and log any exceptions."""
|
"""Start a channel and log any exceptions."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -362,7 +362,11 @@ class MatrixChannel(BaseChannel):
|
|||||||
limit_bytes = await self._effective_media_limit_bytes()
|
limit_bytes = await self._effective_media_limit_bytes()
|
||||||
for path in candidates:
|
for path in candidates:
|
||||||
if fail := await self._upload_and_send_attachment(
|
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)
|
failures.append(fail)
|
||||||
if failures:
|
if failures:
|
||||||
text = f"{text.rstrip()}\n{chr(10).join(failures)}" if text.strip() else "\n".join(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)
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
async def _on_room_invite(self, room: MatrixRoom, event: InviteEvent) -> None:
|
||||||
allow_from = self.config.allow_from or []
|
if self.is_allowed(event.sender):
|
||||||
if not allow_from or event.sender in allow_from:
|
|
||||||
await self.client.join(room.room_id)
|
await self.client.join(room.room_id)
|
||||||
|
|
||||||
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
def _is_direct_room(self, room: MatrixRoom) -> bool:
|
||||||
@@ -676,11 +679,13 @@ class MatrixChannel(BaseChannel):
|
|||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
if isinstance(body := getattr(event, "body", None), str) and body.strip():
|
if isinstance(body := getattr(event, "body", None), str) and body.strip():
|
||||||
parts.append(body.strip())
|
parts.append(body.strip())
|
||||||
parts.append(marker)
|
if marker:
|
||||||
|
parts.append(marker)
|
||||||
|
|
||||||
await self._start_typing_keepalive(room.room_id)
|
await self._start_typing_keepalive(room.room_id)
|
||||||
try:
|
try:
|
||||||
meta = self._base_metadata(room, event)
|
meta = self._base_metadata(room, event)
|
||||||
|
meta["attachments"] = []
|
||||||
if attachment:
|
if attachment:
|
||||||
meta["attachments"] = [attachment]
|
meta["attachments"] = [attachment]
|
||||||
await self._handle_message(
|
await self._handle_message(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ def gateway(
|
|||||||
# Set cron callback (needs agent)
|
# Set cron callback (needs agent)
|
||||||
async def on_cron_job(job: CronJob) -> str | None:
|
async def on_cron_job(job: CronJob) -> str | None:
|
||||||
"""Execute a cron job through the agent."""
|
"""Execute a cron job through the agent."""
|
||||||
|
from nanobot.agent.tools.cron import CronTool
|
||||||
from nanobot.agent.tools.message import MessageTool
|
from nanobot.agent.tools.message import MessageTool
|
||||||
reminder_note = (
|
reminder_note = (
|
||||||
"[Scheduled Task] Timer finished.\n\n"
|
"[Scheduled Task] Timer finished.\n\n"
|
||||||
@@ -303,12 +304,21 @@ def gateway(
|
|||||||
f"Scheduled instruction: {job.payload.message}"
|
f"Scheduled instruction: {job.payload.message}"
|
||||||
)
|
)
|
||||||
|
|
||||||
response = await agent.process_direct(
|
# Prevent the agent from scheduling new cron jobs during execution
|
||||||
reminder_note,
|
cron_tool = agent.tools.get("cron")
|
||||||
session_key=f"cron:{job.id}",
|
cron_token = None
|
||||||
channel=job.payload.channel or "cli",
|
if isinstance(cron_tool, CronTool):
|
||||||
chat_id=job.payload.to or "direct",
|
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")
|
message_tool = agent.tools.get("message")
|
||||||
if isinstance(message_tool, MessageTool) and message_tool._sent_in_turn:
|
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]")
|
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
|
# Status Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ class SlackConfig(Base):
|
|||||||
user_token_read_only: bool = True
|
user_token_read_only: bool = True
|
||||||
reply_in_thread: bool = True
|
reply_in_thread: bool = True
|
||||||
react_emoji: str = "eyes"
|
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_policy: str = "mention" # "mention", "open", "allowlist"
|
||||||
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
||||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ class CronService:
|
|||||||
|
|
||||||
async def _on_timer(self) -> None:
|
async def _on_timer(self) -> None:
|
||||||
"""Handle timer tick - run due jobs."""
|
"""Handle timer tick - run due jobs."""
|
||||||
|
self._load_store()
|
||||||
if not self._store:
|
if not self._store:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ class LLMProvider(ABC):
|
|||||||
result.append(clean)
|
result.append(clean)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if isinstance(content, dict):
|
||||||
|
clean = dict(msg)
|
||||||
|
clean["content"] = [content]
|
||||||
|
result.append(clean)
|
||||||
|
continue
|
||||||
|
|
||||||
result.append(msg)
|
result.append(msg)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ from litellm import acompletion
|
|||||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
from nanobot.providers.registry import find_by_model, find_gateway
|
from nanobot.providers.registry import find_by_model, find_gateway
|
||||||
|
|
||||||
# Standard OpenAI chat-completion message keys plus reasoning_content for
|
# Standard chat-completion message keys.
|
||||||
# thinking-enabled models (Kimi k2.5, DeepSeek-R1, etc.).
|
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content"})
|
||||||
_ALLOWED_MSG_KEYS = frozenset({"role", "content", "tool_calls", "tool_call_id", "name", "reasoning_content", "thinking_blocks"})
|
_ANTHROPIC_EXTRA_KEYS = frozenset({"thinking_blocks"})
|
||||||
_ALNUM = string.ascii_letters + string.digits
|
_ALNUM = string.ascii_letters + string.digits
|
||||||
|
|
||||||
def _short_tool_id() -> str:
|
def _short_tool_id() -> str:
|
||||||
@@ -158,11 +158,20 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
return
|
return
|
||||||
|
|
||||||
@staticmethod
|
@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."""
|
"""Strip non-standard keys and ensure assistant messages have a content key."""
|
||||||
|
allowed = _ALLOWED_MSG_KEYS | extra_keys
|
||||||
sanitized = []
|
sanitized = []
|
||||||
for msg in messages:
|
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
|
# Strict providers require "content" even when assistant only has tool_calls
|
||||||
if clean.get("role") == "assistant" and "content" not in clean:
|
if clean.get("role") == "assistant" and "content" not in clean:
|
||||||
clean["content"] = None
|
clean["content"] = None
|
||||||
@@ -193,6 +202,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
"""
|
"""
|
||||||
original_model = model or self.default_model
|
original_model = model or self.default_model
|
||||||
model = self._resolve_model(original_model)
|
model = self._resolve_model(original_model)
|
||||||
|
extra_msg_keys = self._extra_msg_keys(original_model, model)
|
||||||
|
|
||||||
if self._supports_cache_control(original_model):
|
if self._supports_cache_control(original_model):
|
||||||
messages, tools = self._apply_cache_control(messages, tools)
|
messages, tools = self._apply_cache_control(messages, tools)
|
||||||
@@ -203,7 +213,7 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model,
|
"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,
|
"max_tokens": max_tokens,
|
||||||
"temperature": temperature,
|
"temperature": temperature,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ You are a helpful AI assistant. Be concise, accurate, and friendly.
|
|||||||
|
|
||||||
## Scheduled Reminders
|
## Scheduled Reminders
|
||||||
|
|
||||||
When user asks for a reminder at a specific time, use `exec` to run:
|
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`).
|
||||||
nanobot cron add --name "reminder" --message "Your message" --at "YYYY-MM-DDTHH:MM:SS" --deliver --to "USER_ID" --channel "CHANNEL"
|
|
||||||
```
|
|
||||||
Get USER_ID and CHANNEL from the current session (e.g., `8281248569` and `telegram` from `telegram:8281248569`).
|
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.
|
**Do NOT just write reminders to MEMORY.md** — that won't trigger actual notifications.
|
||||||
|
|
||||||
## Heartbeat Tasks
|
## 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
|
- **Add**: `edit_file` to append new tasks
|
||||||
- **Remove**: `edit_file` to delete completed tasks
|
- **Remove**: `edit_file` to delete completed tasks
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ 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",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
@@ -54,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()
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from nanobot.cron.service import CronService
|
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.schedule.tz == "America/Vancouver"
|
||||||
assert job.state.next_run_at_ms is not None
|
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:
|
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