Merge remote-tracking branch 'origin/main' into pr-1573
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.worktrees/
|
||||||
.assets
|
.assets
|
||||||
.env
|
.env
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
41
README.md
41
README.md
@@ -12,11 +12,11 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw)
|
🐈 **nanobot** is an **ultra-lightweight** personal AI assistant inspired by [OpenClaw](https://github.com/openclaw/openclaw).
|
||||||
|
|
||||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
⚡️ Delivers core agent functionality with **99% fewer lines of code** than OpenClaw.
|
||||||
|
|
||||||
📏 Real-time line count: **3,935 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
📏 Real-time line count: run `bash core_agent_lines.sh` to verify anytime.
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
@@ -293,12 +293,18 @@ If you prefer to configure manually, add the following to `~/.nanobot/config.jso
|
|||||||
"discord": {
|
"discord": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "YOUR_BOT_TOKEN",
|
"token": "YOUR_BOT_TOKEN",
|
||||||
"allowFrom": ["YOUR_USER_ID"]
|
"allowFrom": ["YOUR_USER_ID"],
|
||||||
|
"groupPolicy": "mention"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> `groupPolicy` controls how the bot responds in group channels:
|
||||||
|
> - `"mention"` (default) — Only respond when @mentioned
|
||||||
|
> - `"open"` — Respond to all messages
|
||||||
|
> DMs always respond when the sender is in `allowFrom`.
|
||||||
|
|
||||||
**5. Invite the bot**
|
**5. Invite the bot**
|
||||||
- OAuth2 → URL Generator
|
- OAuth2 → URL Generator
|
||||||
- Scopes: `bot`
|
- Scopes: `bot`
|
||||||
@@ -884,6 +890,33 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
|
|||||||
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
|
| `channels.*.allowFrom` | `[]` (allow all) | Whitelist of user IDs. Empty = allow everyone; non-empty = only listed users can interact. |
|
||||||
|
|
||||||
|
|
||||||
|
## Multiple Instances
|
||||||
|
|
||||||
|
Run multiple nanobot instances simultaneously, each with its own workspace and configuration.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Instance A - Telegram bot
|
||||||
|
nanobot gateway -w ~/.nanobot/botA -p 18791
|
||||||
|
|
||||||
|
# Instance B - Discord bot
|
||||||
|
nanobot gateway -w ~/.nanobot/botB -p 18792
|
||||||
|
|
||||||
|
# Instance C - Using custom config file
|
||||||
|
nanobot gateway -w ~/.nanobot/botC -c ~/.nanobot/botC/config.json -p 18793
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Short | Description |
|
||||||
|
|--------|-------|-------------|
|
||||||
|
| `--workspace` | `-w` | Workspace directory (default: `~/.nanobot/workspace`) |
|
||||||
|
| `--config` | `-c` | Config file path (default: `~/.nanobot/config.json`) |
|
||||||
|
| `--port` | `-p` | Gateway port (default: `18790`) |
|
||||||
|
|
||||||
|
Each instance has its own:
|
||||||
|
- Workspace directory (MEMORY.md, HEARTBEAT.md, session files)
|
||||||
|
- Cron jobs storage (`workspace/cron/jobs.json`)
|
||||||
|
- Configuration (if using `--config`)
|
||||||
|
|
||||||
|
|
||||||
## CLI Reference
|
## CLI Reference
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|
|||||||
@@ -202,9 +202,18 @@ class AgentLoop:
|
|||||||
|
|
||||||
if response.has_tool_calls:
|
if response.has_tool_calls:
|
||||||
if on_progress:
|
if on_progress:
|
||||||
clean = self._strip_think(response.content)
|
thoughts = [
|
||||||
if clean:
|
self._strip_think(response.content),
|
||||||
await on_progress(clean)
|
response.reasoning_content,
|
||||||
|
*(
|
||||||
|
f"Thinking [{b.get('signature', '...')}]:\n{b.get('thought', '...')}"
|
||||||
|
for b in (response.thinking_blocks or [])
|
||||||
|
if isinstance(b, dict) and "signature" in b
|
||||||
|
),
|
||||||
|
]
|
||||||
|
combined_thoughts = "\n\n".join(filter(None, thoughts))
|
||||||
|
if combined_thoughts:
|
||||||
|
await on_progress(combined_thoughts)
|
||||||
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
|
await on_progress(self._tool_hint(response.tool_calls), tool_hint=True)
|
||||||
|
|
||||||
tool_call_dicts = [
|
tool_call_dicts = [
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ class MemoryStore:
|
|||||||
# Some providers return arguments as a JSON string instead of dict
|
# Some providers return arguments as a JSON string instead of dict
|
||||||
if isinstance(args, str):
|
if isinstance(args, str):
|
||||||
args = json.loads(args)
|
args = json.loads(args)
|
||||||
|
# Some providers return arguments as a list (handle edge case)
|
||||||
|
if isinstance(args, list):
|
||||||
|
if args and isinstance(args[0], dict):
|
||||||
|
args = args[0]
|
||||||
|
else:
|
||||||
|
logger.warning("Memory consolidation: unexpected arguments as empty or non-dict list")
|
||||||
|
return False
|
||||||
if not isinstance(args, dict):
|
if not isinstance(args, dict):
|
||||||
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
|
logger.warning("Memory consolidation: unexpected arguments type {}", type(args).__name__)
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -58,17 +58,48 @@ async def connect_mcp_servers(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Connect to configured MCP servers and register their tools."""
|
"""Connect to configured MCP servers and register their tools."""
|
||||||
from mcp import ClientSession, StdioServerParameters
|
from mcp import ClientSession, StdioServerParameters
|
||||||
|
from mcp.client.sse import sse_client
|
||||||
from mcp.client.stdio import stdio_client
|
from mcp.client.stdio import stdio_client
|
||||||
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
for name, cfg in mcp_servers.items():
|
for name, cfg in mcp_servers.items():
|
||||||
try:
|
try:
|
||||||
if cfg.command:
|
transport_type = cfg.type
|
||||||
|
if not transport_type:
|
||||||
|
if cfg.command:
|
||||||
|
transport_type = "stdio"
|
||||||
|
elif cfg.url:
|
||||||
|
# Convention: URLs ending with /sse use SSE transport; others use streamableHttp
|
||||||
|
transport_type = (
|
||||||
|
"sse" if cfg.url.rstrip("/").endswith("/sse") else "streamableHttp"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if transport_type == "stdio":
|
||||||
params = StdioServerParameters(
|
params = StdioServerParameters(
|
||||||
command=cfg.command, args=cfg.args, env=cfg.env or None
|
command=cfg.command, args=cfg.args, env=cfg.env or None
|
||||||
)
|
)
|
||||||
read, write = await stack.enter_async_context(stdio_client(params))
|
read, write = await stack.enter_async_context(stdio_client(params))
|
||||||
elif cfg.url:
|
elif transport_type == "sse":
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
def httpx_client_factory(
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: httpx.Timeout | None = None,
|
||||||
|
auth: httpx.Auth | None = None,
|
||||||
|
) -> httpx.AsyncClient:
|
||||||
|
merged_headers = {**(cfg.headers or {}), **(headers or {})}
|
||||||
|
return httpx.AsyncClient(
|
||||||
|
headers=merged_headers or None,
|
||||||
|
follow_redirects=True,
|
||||||
|
timeout=timeout,
|
||||||
|
auth=auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
read, write = await stack.enter_async_context(
|
||||||
|
sse_client(cfg.url, httpx_client_factory=httpx_client_factory)
|
||||||
|
)
|
||||||
|
elif transport_type == "streamableHttp":
|
||||||
# Always provide an explicit httpx client so MCP HTTP transport does not
|
# Always provide an explicit httpx client so MCP HTTP transport does not
|
||||||
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
|
# inherit httpx's default 5s timeout and preempt the higher-level tool timeout.
|
||||||
http_client = await stack.enter_async_context(
|
http_client = await stack.enter_async_context(
|
||||||
@@ -82,7 +113,7 @@ async def connect_mcp_servers(
|
|||||||
streamable_http_client(cfg.url, http_client=http_client)
|
streamable_http_client(cfg.url, http_client=http_client)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning("MCP server '{}': no command or url configured, skipping", name)
|
logger.warning("MCP server '{}': unknown transport type '{}'", name, transport_type)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
session = await stack.enter_async_context(ClientSession(read, write))
|
session = await stack.enter_async_context(ClientSession(read, write))
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
self._heartbeat_task: asyncio.Task | None = None
|
self._heartbeat_task: asyncio.Task | None = None
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {}
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._http: httpx.AsyncClient | None = None
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
self._bot_user_id: str | None = None
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Discord gateway connection."""
|
"""Start the Discord gateway connection."""
|
||||||
@@ -170,6 +171,10 @@ class DiscordChannel(BaseChannel):
|
|||||||
await self._identify()
|
await self._identify()
|
||||||
elif op == 0 and event_type == "READY":
|
elif op == 0 and event_type == "READY":
|
||||||
logger.info("Discord gateway READY")
|
logger.info("Discord gateway READY")
|
||||||
|
# Capture bot user ID for mention detection
|
||||||
|
user_data = payload.get("user") or {}
|
||||||
|
self._bot_user_id = user_data.get("id")
|
||||||
|
logger.info("Discord bot connected as user {}", self._bot_user_id)
|
||||||
elif op == 0 and event_type == "MESSAGE_CREATE":
|
elif op == 0 and event_type == "MESSAGE_CREATE":
|
||||||
await self._handle_message_create(payload)
|
await self._handle_message_create(payload)
|
||||||
elif op == 7:
|
elif op == 7:
|
||||||
@@ -226,6 +231,7 @@ class DiscordChannel(BaseChannel):
|
|||||||
sender_id = str(author.get("id", ""))
|
sender_id = str(author.get("id", ""))
|
||||||
channel_id = str(payload.get("channel_id", ""))
|
channel_id = str(payload.get("channel_id", ""))
|
||||||
content = payload.get("content") or ""
|
content = payload.get("content") or ""
|
||||||
|
guild_id = payload.get("guild_id")
|
||||||
|
|
||||||
if not sender_id or not channel_id:
|
if not sender_id or not channel_id:
|
||||||
return
|
return
|
||||||
@@ -233,6 +239,11 @@ class DiscordChannel(BaseChannel):
|
|||||||
if not self.is_allowed(sender_id):
|
if not self.is_allowed(sender_id):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check group channel policy (DMs always respond if is_allowed passes)
|
||||||
|
if guild_id is not None:
|
||||||
|
if not self._should_respond_in_group(payload, content):
|
||||||
|
return
|
||||||
|
|
||||||
content_parts = [content] if content else []
|
content_parts = [content] if content else []
|
||||||
media_paths: list[str] = []
|
media_paths: list[str] = []
|
||||||
media_dir = Path.home() / ".nanobot" / "media"
|
media_dir = Path.home() / ".nanobot" / "media"
|
||||||
@@ -269,11 +280,32 @@ class DiscordChannel(BaseChannel):
|
|||||||
media=media_paths,
|
media=media_paths,
|
||||||
metadata={
|
metadata={
|
||||||
"message_id": str(payload.get("id", "")),
|
"message_id": str(payload.get("id", "")),
|
||||||
"guild_id": payload.get("guild_id"),
|
"guild_id": guild_id,
|
||||||
"reply_to": reply_to,
|
"reply_to": reply_to,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _should_respond_in_group(self, payload: dict[str, Any], content: str) -> bool:
|
||||||
|
"""Check if bot should respond in a group channel based on policy."""
|
||||||
|
if self.config.group_policy == "open":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if self.config.group_policy == "mention":
|
||||||
|
# Check if bot was mentioned in the message
|
||||||
|
if self._bot_user_id:
|
||||||
|
# Check mentions array
|
||||||
|
mentions = payload.get("mentions") or []
|
||||||
|
for mention in mentions:
|
||||||
|
if str(mention.get("id")) == self._bot_user_id:
|
||||||
|
return True
|
||||||
|
# Also check content for mention format <@USER_ID>
|
||||||
|
if f"<@{self._bot_user_id}>" in content or f"<@!{self._bot_user_id}>" in content:
|
||||||
|
return True
|
||||||
|
logger.debug("Discord message in {} ignored (bot not mentioned)", payload.get("channel_id"))
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def _start_typing(self, channel_id: str) -> None:
|
async def _start_typing(self, channel_id: str) -> None:
|
||||||
"""Start periodic typing indicator for a channel."""
|
"""Start periodic typing indicator for a channel."""
|
||||||
await self._stop_typing(channel_id)
|
await self._stop_typing(channel_id)
|
||||||
|
|||||||
@@ -410,6 +410,34 @@ class FeishuChannel(BaseChannel):
|
|||||||
elements.extend(self._split_headings(remaining))
|
elements.extend(self._split_headings(remaining))
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split_elements_by_table_limit(elements: list[dict], max_tables: int = 1) -> list[list[dict]]:
|
||||||
|
"""Split card elements into groups with at most *max_tables* table elements each.
|
||||||
|
|
||||||
|
Feishu cards have a hard limit of one table per card (API error 11310).
|
||||||
|
When the rendered content contains multiple markdown tables each table is
|
||||||
|
placed in a separate card message so every table reaches the user.
|
||||||
|
"""
|
||||||
|
if not elements:
|
||||||
|
return [[]]
|
||||||
|
groups: list[list[dict]] = []
|
||||||
|
current: list[dict] = []
|
||||||
|
table_count = 0
|
||||||
|
for el in elements:
|
||||||
|
if el.get("tag") == "table":
|
||||||
|
if table_count >= max_tables:
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
current = []
|
||||||
|
table_count = 0
|
||||||
|
current.append(el)
|
||||||
|
table_count += 1
|
||||||
|
else:
|
||||||
|
current.append(el)
|
||||||
|
if current:
|
||||||
|
groups.append(current)
|
||||||
|
return groups or [[]]
|
||||||
|
|
||||||
def _split_headings(self, content: str) -> list[dict]:
|
def _split_headings(self, content: str) -> list[dict]:
|
||||||
"""Split content by headings, converting headings to div elements."""
|
"""Split content by headings, converting headings to div elements."""
|
||||||
protected = content
|
protected = content
|
||||||
@@ -444,8 +472,124 @@ class FeishuChannel(BaseChannel):
|
|||||||
|
|
||||||
return elements or [{"tag": "markdown", "content": content}]
|
return elements or [{"tag": "markdown", "content": content}]
|
||||||
|
|
||||||
|
# ── Smart format detection ──────────────────────────────────────────
|
||||||
|
# Patterns that indicate "complex" markdown needing card rendering
|
||||||
|
_COMPLEX_MD_RE = re.compile(
|
||||||
|
r"```" # fenced code block
|
||||||
|
r"|^\|.+\|.*\n\s*\|[-:\s|]+\|" # markdown table (header + separator)
|
||||||
|
r"|^#{1,6}\s+" # headings
|
||||||
|
, re.MULTILINE,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simple markdown patterns (bold, italic, strikethrough)
|
||||||
|
_SIMPLE_MD_RE = re.compile(
|
||||||
|
r"\*\*.+?\*\*" # **bold**
|
||||||
|
r"|__.+?__" # __bold__
|
||||||
|
r"|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)" # *italic* (single *)
|
||||||
|
r"|~~.+?~~" # ~~strikethrough~~
|
||||||
|
, re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Markdown link: [text](url)
|
||||||
|
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\)]+)\)")
|
||||||
|
|
||||||
|
# Unordered list items
|
||||||
|
_LIST_RE = re.compile(r"^[\s]*[-*+]\s+", re.MULTILINE)
|
||||||
|
|
||||||
|
# Ordered list items
|
||||||
|
_OLIST_RE = re.compile(r"^[\s]*\d+\.\s+", re.MULTILINE)
|
||||||
|
|
||||||
|
# Max length for plain text format
|
||||||
|
_TEXT_MAX_LEN = 200
|
||||||
|
|
||||||
|
# Max length for post (rich text) format; beyond this, use card
|
||||||
|
_POST_MAX_LEN = 2000
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _detect_msg_format(cls, content: str) -> str:
|
||||||
|
"""Determine the optimal Feishu message format for *content*.
|
||||||
|
|
||||||
|
Returns one of:
|
||||||
|
- ``"text"`` – plain text, short and no markdown
|
||||||
|
- ``"post"`` – rich text (links only, moderate length)
|
||||||
|
- ``"interactive"`` – card with full markdown rendering
|
||||||
|
"""
|
||||||
|
stripped = content.strip()
|
||||||
|
|
||||||
|
# Complex markdown (code blocks, tables, headings) → always card
|
||||||
|
if cls._COMPLEX_MD_RE.search(stripped):
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Long content → card (better readability with card layout)
|
||||||
|
if len(stripped) > cls._POST_MAX_LEN:
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Has bold/italic/strikethrough → card (post format can't render these)
|
||||||
|
if cls._SIMPLE_MD_RE.search(stripped):
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Has list items → card (post format can't render list bullets well)
|
||||||
|
if cls._LIST_RE.search(stripped) or cls._OLIST_RE.search(stripped):
|
||||||
|
return "interactive"
|
||||||
|
|
||||||
|
# Has links → post format (supports <a> tags)
|
||||||
|
if cls._MD_LINK_RE.search(stripped):
|
||||||
|
return "post"
|
||||||
|
|
||||||
|
# Short plain text → text format
|
||||||
|
if len(stripped) <= cls._TEXT_MAX_LEN:
|
||||||
|
return "text"
|
||||||
|
|
||||||
|
# Medium plain text without any formatting → post format
|
||||||
|
return "post"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _markdown_to_post(cls, content: str) -> str:
|
||||||
|
"""Convert markdown content to Feishu post message JSON.
|
||||||
|
|
||||||
|
Handles links ``[text](url)`` as ``a`` tags; everything else as ``text`` tags.
|
||||||
|
Each line becomes a paragraph (row) in the post body.
|
||||||
|
"""
|
||||||
|
lines = content.strip().split("\n")
|
||||||
|
paragraphs: list[list[dict]] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
elements: list[dict] = []
|
||||||
|
last_end = 0
|
||||||
|
|
||||||
|
for m in cls._MD_LINK_RE.finditer(line):
|
||||||
|
# Text before this link
|
||||||
|
before = line[last_end:m.start()]
|
||||||
|
if before:
|
||||||
|
elements.append({"tag": "text", "text": before})
|
||||||
|
elements.append({
|
||||||
|
"tag": "a",
|
||||||
|
"text": m.group(1),
|
||||||
|
"href": m.group(2),
|
||||||
|
})
|
||||||
|
last_end = m.end()
|
||||||
|
|
||||||
|
# Remaining text after last link
|
||||||
|
remaining = line[last_end:]
|
||||||
|
if remaining:
|
||||||
|
elements.append({"tag": "text", "text": remaining})
|
||||||
|
|
||||||
|
# Empty line → empty paragraph for spacing
|
||||||
|
if not elements:
|
||||||
|
elements.append({"tag": "text", "text": ""})
|
||||||
|
|
||||||
|
paragraphs.append(elements)
|
||||||
|
|
||||||
|
post_body = {
|
||||||
|
"zh_cn": {
|
||||||
|
"content": paragraphs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(post_body, ensure_ascii=False)
|
||||||
|
|
||||||
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".ico", ".tiff", ".tif"}
|
||||||
_AUDIO_EXTS = {".opus"}
|
_AUDIO_EXTS = {".opus"}
|
||||||
|
_VIDEO_EXTS = {".mp4", ".mov", ".avi"}
|
||||||
_FILE_TYPE_MAP = {
|
_FILE_TYPE_MAP = {
|
||||||
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
".opus": "opus", ".mp4": "mp4", ".pdf": "pdf", ".doc": "doc", ".docx": "doc",
|
||||||
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
|
".xls": "xls", ".xlsx": "xls", ".ppt": "ppt", ".pptx": "ppt",
|
||||||
@@ -654,18 +798,45 @@ class FeishuChannel(BaseChannel):
|
|||||||
else:
|
else:
|
||||||
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
key = await loop.run_in_executor(None, self._upload_file_sync, file_path)
|
||||||
if key:
|
if key:
|
||||||
media_type = "audio" if ext in self._AUDIO_EXTS else "file"
|
# Use msg_type "media" for audio/video so users can play inline;
|
||||||
|
# "file" for everything else (documents, archives, etc.)
|
||||||
|
if ext in self._AUDIO_EXTS or ext in self._VIDEO_EXTS:
|
||||||
|
media_type = "media"
|
||||||
|
else:
|
||||||
|
media_type = "file"
|
||||||
await loop.run_in_executor(
|
await loop.run_in_executor(
|
||||||
None, self._send_message_sync,
|
None, self._send_message_sync,
|
||||||
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
|
receive_id_type, msg.chat_id, media_type, json.dumps({"file_key": key}, ensure_ascii=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
if msg.content and msg.content.strip():
|
if msg.content and msg.content.strip():
|
||||||
card = {"config": {"wide_screen_mode": True}, "elements": self._build_card_elements(msg.content)}
|
fmt = self._detect_msg_format(msg.content)
|
||||||
await loop.run_in_executor(
|
|
||||||
None, self._send_message_sync,
|
if fmt == "text":
|
||||||
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
# Short plain text – send as simple text message
|
||||||
)
|
text_body = json.dumps({"text": msg.content.strip()}, ensure_ascii=False)
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "text", text_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
elif fmt == "post":
|
||||||
|
# Medium content with links – send as rich-text post
|
||||||
|
post_body = self._markdown_to_post(msg.content)
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "post", post_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Complex / long content – send as interactive card
|
||||||
|
elements = self._build_card_elements(msg.content)
|
||||||
|
for chunk in self._split_elements_by_table_limit(elements):
|
||||||
|
card = {"config": {"wide_screen_mode": True}, "elements": chunk}
|
||||||
|
await loop.run_in_executor(
|
||||||
|
None, self._send_message_sync,
|
||||||
|
receive_id_type, msg.chat_id, "interactive", json.dumps(card, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error sending Feishu message: {}", e)
|
logger.error("Error sending Feishu message: {}", e)
|
||||||
|
|||||||
@@ -225,7 +225,9 @@ class TelegramChannel(BaseChannel):
|
|||||||
logger.warning("Telegram bot not running")
|
logger.warning("Telegram bot not running")
|
||||||
return
|
return
|
||||||
|
|
||||||
self._stop_typing(msg.chat_id)
|
# Only stop typing indicator for final responses
|
||||||
|
if not msg.metadata.get("_progress", False):
|
||||||
|
self._stop_typing(msg.chat_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
chat_id = int(msg.chat_id)
|
chat_id = int(msg.chat_id)
|
||||||
@@ -269,23 +271,41 @@ class TelegramChannel(BaseChannel):
|
|||||||
|
|
||||||
# Send text content
|
# Send text content
|
||||||
if msg.content and msg.content != "[empty message]":
|
if msg.content and msg.content != "[empty message]":
|
||||||
|
is_progress = msg.metadata.get("_progress", False)
|
||||||
|
draft_id = msg.metadata.get("message_id")
|
||||||
|
|
||||||
for chunk in _split_message(msg.content):
|
for chunk in _split_message(msg.content):
|
||||||
try:
|
try:
|
||||||
html = _markdown_to_telegram_html(chunk)
|
html = _markdown_to_telegram_html(chunk)
|
||||||
await self._app.bot.send_message(
|
if is_progress and draft_id:
|
||||||
chat_id=chat_id,
|
await self._app.bot.send_message_draft(
|
||||||
text=html,
|
chat_id=chat_id,
|
||||||
parse_mode="HTML",
|
draft_id=draft_id,
|
||||||
reply_parameters=reply_params
|
text=html,
|
||||||
)
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=html,
|
||||||
|
parse_mode="HTML",
|
||||||
|
reply_parameters=reply_params
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
logger.warning("HTML parse failed, falling back to plain text: {}", e)
|
||||||
try:
|
try:
|
||||||
await self._app.bot.send_message(
|
if is_progress and draft_id:
|
||||||
chat_id=chat_id,
|
await self._app.bot.send_message_draft(
|
||||||
text=chunk,
|
chat_id=chat_id,
|
||||||
reply_parameters=reply_params
|
draft_id=draft_id,
|
||||||
)
|
text=chunk
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._app.bot.send_message(
|
||||||
|
chat_id=chat_id,
|
||||||
|
text=chunk,
|
||||||
|
reply_parameters=reply_params
|
||||||
|
)
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.error("Error sending Telegram message: {}", e2)
|
logger.error("Error sending Telegram message: {}", e2)
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,18 @@ import signal
|
|||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Force UTF-8 encoding for Windows console
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import locale
|
||||||
|
if sys.stdout.encoding != "utf-8":
|
||||||
|
os.environ["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
# Re-open stdout/stderr with UTF-8 encoding
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
from prompt_toolkit import PromptSession
|
from prompt_toolkit import PromptSession
|
||||||
from prompt_toolkit.formatted_text import HTML
|
from prompt_toolkit.formatted_text import HTML
|
||||||
@@ -244,13 +256,15 @@ def _make_provider(config: Config):
|
|||||||
@app.command()
|
@app.command()
|
||||||
def gateway(
|
def gateway(
|
||||||
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
port: int = typer.Option(18790, "--port", "-p", help="Gateway port"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Config file path"),
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
):
|
):
|
||||||
"""Start the nanobot gateway."""
|
"""Start the nanobot gateway."""
|
||||||
from nanobot.agent.loop import AgentLoop
|
from nanobot.agent.loop import AgentLoop
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
from nanobot.config.loader import get_data_dir, load_config
|
from nanobot.config.loader import load_config
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
from nanobot.cron.types import CronJob
|
from nanobot.cron.types import CronJob
|
||||||
from nanobot.heartbeat.service import HeartbeatService
|
from nanobot.heartbeat.service import HeartbeatService
|
||||||
@@ -260,16 +274,20 @@ def gateway(
|
|||||||
import logging
|
import logging
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
config_path = Path(config) if config else None
|
||||||
|
config = load_config(config_path)
|
||||||
|
if workspace:
|
||||||
|
config.agents.defaults.workspace = workspace
|
||||||
|
|
||||||
config = load_config()
|
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||||
sync_workspace_templates(config.workspace_path)
|
sync_workspace_templates(config.workspace_path)
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
session_manager = SessionManager(config.workspace_path)
|
session_manager = SessionManager(config.workspace_path)
|
||||||
|
|
||||||
# Create cron service first (callback set after agent creation)
|
# Create cron service first (callback set after agent creation)
|
||||||
cron_store_path = get_data_dir() / "cron" / "jobs.json"
|
# Use workspace path for per-instance cron store
|
||||||
|
cron_store_path = config.workspace_path / "cron" / "jobs.json"
|
||||||
cron = CronService(cron_store_path)
|
cron = CronService(cron_store_path)
|
||||||
|
|
||||||
# Create agent with cron service
|
# Create agent with cron service
|
||||||
@@ -511,12 +529,21 @@ def agent(
|
|||||||
else:
|
else:
|
||||||
cli_channel, cli_chat_id = "cli", session_id
|
cli_channel, cli_chat_id = "cli", session_id
|
||||||
|
|
||||||
def _exit_on_sigint(signum, frame):
|
def _handle_signal(signum, frame):
|
||||||
|
sig_name = signal.Signals(signum).name
|
||||||
_restore_terminal()
|
_restore_terminal()
|
||||||
console.print("\nGoodbye!")
|
console.print(f"\nReceived {sig_name}, goodbye!")
|
||||||
os._exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, _exit_on_sigint)
|
signal.signal(signal.SIGINT, _handle_signal)
|
||||||
|
signal.signal(signal.SIGTERM, _handle_signal)
|
||||||
|
# SIGHUP is not available on Windows
|
||||||
|
if hasattr(signal, 'SIGHUP'):
|
||||||
|
signal.signal(signal.SIGHUP, _handle_signal)
|
||||||
|
# Ignore SIGPIPE to prevent silent process termination when writing to closed pipes
|
||||||
|
# SIGPIPE is not available on Windows
|
||||||
|
if hasattr(signal, 'SIGPIPE'):
|
||||||
|
signal.signal(signal.SIGPIPE, signal.SIG_IGN)
|
||||||
|
|
||||||
async def run_interactive():
|
async def run_interactive():
|
||||||
bus_task = asyncio.create_task(agent_loop.run())
|
bus_task = asyncio.create_task(agent_loop.run())
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ class TelegramConfig(Base):
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
token: str = "" # Bot token from @BotFather
|
token: str = "" # Bot token from @BotFather
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
|
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
|
||||||
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
proxy: str | None = (
|
||||||
|
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
||||||
|
)
|
||||||
reply_to_message: bool = False # If true, bot replies quote the original message
|
reply_to_message: bool = False # If true, bot replies quote the original message
|
||||||
|
|
||||||
|
|
||||||
@@ -42,7 +44,9 @@ class FeishuConfig(Base):
|
|||||||
encrypt_key: str = "" # Encrypt Key for event subscription (optional)
|
encrypt_key: str = "" # Encrypt Key for event subscription (optional)
|
||||||
verification_token: str = "" # Verification Token for event subscription (optional)
|
verification_token: str = "" # Verification Token for event subscription (optional)
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
||||||
react_emoji: str = "THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
|
react_emoji: str = (
|
||||||
|
"THUMBSUP" # Emoji type for message reactions (e.g. THUMBSUP, OK, DONE, SMILE)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class DingTalkConfig(Base):
|
class DingTalkConfig(Base):
|
||||||
@@ -62,6 +66,7 @@ class DiscordConfig(Base):
|
|||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
||||||
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
||||||
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
||||||
|
group_policy: Literal["mention", "open"] = "mention"
|
||||||
|
|
||||||
|
|
||||||
class MatrixConfig(Base):
|
class MatrixConfig(Base):
|
||||||
@@ -72,9 +77,13 @@ class MatrixConfig(Base):
|
|||||||
access_token: str = ""
|
access_token: str = ""
|
||||||
user_id: str = "" # @bot:matrix.org
|
user_id: str = "" # @bot:matrix.org
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling).
|
e2ee_enabled: bool = True # Enable Matrix E2EE support (encryption + encrypted room handling).
|
||||||
sync_stop_grace_seconds: int = 2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
sync_stop_grace_seconds: int = (
|
||||||
max_media_bytes: int = 20 * 1024 * 1024 # Max attachment size accepted for Matrix media handling (inbound + outbound).
|
2 # Max seconds to wait for sync_forever to stop gracefully before cancellation fallback.
|
||||||
|
)
|
||||||
|
max_media_bytes: int = (
|
||||||
|
20 * 1024 * 1024
|
||||||
|
) # Max attachment size accepted for Matrix media handling (inbound + outbound).
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||||
group_allow_from: list[str] = Field(default_factory=list)
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
@@ -105,7 +114,9 @@ class EmailConfig(Base):
|
|||||||
from_address: str = ""
|
from_address: str = ""
|
||||||
|
|
||||||
# Behavior
|
# Behavior
|
||||||
auto_reply_enabled: bool = True # If false, inbound email is read but no automatic reply is sent
|
auto_reply_enabled: bool = (
|
||||||
|
True # If false, inbound email is read but no automatic reply is sent
|
||||||
|
)
|
||||||
poll_interval_seconds: int = 30
|
poll_interval_seconds: int = 30
|
||||||
mark_seen: bool = True
|
mark_seen: bool = True
|
||||||
max_body_chars: int = 12000
|
max_body_chars: int = 12000
|
||||||
@@ -183,27 +194,32 @@ class QQConfig(Base):
|
|||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
app_id: str = "" # 机器人 ID (AppID) from q.qq.com
|
||||||
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
secret: str = "" # 机器人密钥 (AppSecret) from q.qq.com
|
||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user openids (empty = public access)
|
allow_from: list[str] = Field(
|
||||||
|
default_factory=list
|
||||||
|
) # Allowed user openids (empty = public access)
|
||||||
|
|
||||||
|
|
||||||
class MatrixConfig(Base):
|
class MatrixConfig(Base):
|
||||||
"""Matrix (Element) channel configuration."""
|
"""Matrix (Element) channel configuration."""
|
||||||
|
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
homeserver: str = "https://matrix.org"
|
homeserver: str = "https://matrix.org"
|
||||||
access_token: str = ""
|
access_token: str = ""
|
||||||
user_id: str = "" # e.g. @bot:matrix.org
|
user_id: str = "" # e.g. @bot:matrix.org
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
e2ee_enabled: bool = True # end-to-end encryption support
|
e2ee_enabled: bool = True # end-to-end encryption support
|
||||||
sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout
|
sync_stop_grace_seconds: int = 2 # graceful sync_forever shutdown timeout
|
||||||
max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit
|
max_media_bytes: int = 20 * 1024 * 1024 # inbound + outbound attachment limit
|
||||||
allow_from: list[str] = Field(default_factory=list)
|
allow_from: list[str] = Field(default_factory=list)
|
||||||
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
group_policy: Literal["open", "mention", "allowlist"] = "open"
|
||||||
group_allow_from: list[str] = Field(default_factory=list)
|
group_allow_from: list[str] = Field(default_factory=list)
|
||||||
allow_room_mentions: bool = False
|
allow_room_mentions: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ChannelsConfig(Base):
|
class ChannelsConfig(Base):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels."""
|
||||||
|
|
||||||
send_progress: bool = True # stream agent's text progress to the channel
|
send_progress: bool = True # stream agent's text progress to the channel
|
||||||
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
send_tool_hints: bool = False # stream tool-call hints (e.g. read_file("…"))
|
||||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||||
@@ -222,7 +238,9 @@ class AgentDefaults(Base):
|
|||||||
|
|
||||||
workspace: str = "~/.nanobot/workspace"
|
workspace: str = "~/.nanobot/workspace"
|
||||||
model: str = "anthropic/claude-opus-4-5"
|
model: str = "anthropic/claude-opus-4-5"
|
||||||
provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
provider: str = (
|
||||||
|
"auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection
|
||||||
|
)
|
||||||
max_tokens: int = 8192
|
max_tokens: int = 8192
|
||||||
temperature: float = 0.1
|
temperature: float = 0.1
|
||||||
max_tool_iterations: int = 40
|
max_tool_iterations: int = 40
|
||||||
@@ -260,8 +278,12 @@ class ProvidersConfig(Base):
|
|||||||
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
minimax: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
aihubmix: ProviderConfig = Field(default_factory=ProviderConfig) # AiHubMix API gateway
|
||||||
siliconflow: ProviderConfig = Field(default_factory=ProviderConfig) # SiliconFlow (硅基流动) API gateway
|
siliconflow: ProviderConfig = Field(
|
||||||
volcengine: ProviderConfig = Field(default_factory=ProviderConfig) # VolcEngine (火山引擎) API gateway
|
default_factory=ProviderConfig
|
||||||
|
) # SiliconFlow (硅基流动) API gateway
|
||||||
|
volcengine: ProviderConfig = Field(
|
||||||
|
default_factory=ProviderConfig
|
||||||
|
) # VolcEngine (火山引擎) API gateway
|
||||||
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
|
openai_codex: ProviderConfig = Field(default_factory=ProviderConfig) # OpenAI Codex (OAuth)
|
||||||
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
|
github_copilot: ProviderConfig = Field(default_factory=ProviderConfig) # Github Copilot (OAuth)
|
||||||
|
|
||||||
@@ -291,7 +313,9 @@ class WebSearchConfig(Base):
|
|||||||
class WebToolsConfig(Base):
|
class WebToolsConfig(Base):
|
||||||
"""Web tools configuration."""
|
"""Web tools configuration."""
|
||||||
|
|
||||||
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
proxy: str | None = (
|
||||||
|
None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
|
||||||
|
)
|
||||||
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
search: WebSearchConfig = Field(default_factory=WebSearchConfig)
|
||||||
|
|
||||||
|
|
||||||
@@ -305,12 +329,13 @@ class ExecToolConfig(Base):
|
|||||||
class MCPServerConfig(Base):
|
class MCPServerConfig(Base):
|
||||||
"""MCP server connection configuration (stdio or HTTP)."""
|
"""MCP server connection configuration (stdio or HTTP)."""
|
||||||
|
|
||||||
|
type: Literal["stdio", "sse", "streamableHttp"] | None = None # auto-detected if omitted
|
||||||
command: str = "" # Stdio: command to run (e.g. "npx")
|
command: str = "" # Stdio: command to run (e.g. "npx")
|
||||||
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
args: list[str] = Field(default_factory=list) # Stdio: command arguments
|
||||||
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
env: dict[str, str] = Field(default_factory=dict) # Stdio: extra env vars
|
||||||
url: str = "" # HTTP: streamable HTTP endpoint URL
|
url: str = "" # HTTP/SSE: endpoint URL
|
||||||
headers: dict[str, str] = Field(default_factory=dict) # HTTP: Custom HTTP Headers
|
headers: dict[str, str] = Field(default_factory=dict) # HTTP/SSE: custom headers
|
||||||
tool_timeout: int = 30 # Seconds before a tool call is cancelled
|
tool_timeout: int = 30 # seconds before a tool call is cancelled
|
||||||
|
|
||||||
|
|
||||||
class ToolsConfig(Base):
|
class ToolsConfig(Base):
|
||||||
@@ -336,7 +361,9 @@ class Config(BaseSettings):
|
|||||||
"""Get expanded workspace path."""
|
"""Get expanded workspace path."""
|
||||||
return Path(self.agents.defaults.workspace).expanduser()
|
return Path(self.agents.defaults.workspace).expanduser()
|
||||||
|
|
||||||
def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]:
|
def _match_provider(
|
||||||
|
self, model: str | None = None
|
||||||
|
) -> tuple["ProviderConfig | None", str | None]:
|
||||||
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
"""Match provider config and its registry name. Returns (config, spec_name)."""
|
||||||
from nanobot.providers.registry import PROVIDERS
|
from nanobot.providers.registry import PROVIDERS
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ dependencies = [
|
|||||||
"rich>=14.0.0,<15.0.0",
|
"rich>=14.0.0,<15.0.0",
|
||||||
"croniter>=6.0.0,<7.0.0",
|
"croniter>=6.0.0,<7.0.0",
|
||||||
"dingtalk-stream>=0.24.0,<1.0.0",
|
"dingtalk-stream>=0.24.0,<1.0.0",
|
||||||
"python-telegram-bot[socks]>=22.0,<23.0",
|
"python-telegram-bot[socks]>=22.6,<23.0",
|
||||||
"lark-oapi>=1.5.0,<2.0.0",
|
"lark-oapi>=1.5.0,<2.0.0",
|
||||||
"socksio>=1.0.0,<2.0.0",
|
"socksio>=1.0.0,<2.0.0",
|
||||||
"python-socketio>=5.16.0,<6.0.0",
|
"python-socketio>=5.16.0,<6.0.0",
|
||||||
|
|||||||
104
tests/test_feishu_table_split.py
Normal file
104
tests/test_feishu_table_split.py
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"""Tests for FeishuChannel._split_elements_by_table_limit.
|
||||||
|
|
||||||
|
Feishu cards reject messages that contain more than one table element
|
||||||
|
(API error 11310: card table number over limit). The helper splits a flat
|
||||||
|
list of card elements into groups so that each group contains at most one
|
||||||
|
table, allowing nanobot to send multiple cards instead of failing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from nanobot.channels.feishu import FeishuChannel
|
||||||
|
|
||||||
|
|
||||||
|
def _md(text: str) -> dict:
|
||||||
|
return {"tag": "markdown", "content": text}
|
||||||
|
|
||||||
|
|
||||||
|
def _table() -> dict:
|
||||||
|
return {
|
||||||
|
"tag": "table",
|
||||||
|
"columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
|
||||||
|
"rows": [{"c0": "v"}],
|
||||||
|
"page_size": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
split = FeishuChannel._split_elements_by_table_limit
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_list_returns_single_empty_group() -> None:
|
||||||
|
assert split([]) == [[]]
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_tables_returns_single_group() -> None:
|
||||||
|
els = [_md("hello"), _md("world")]
|
||||||
|
result = split(els)
|
||||||
|
assert result == [els]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_table_stays_in_one_group() -> None:
|
||||||
|
els = [_md("intro"), _table(), _md("outro")]
|
||||||
|
result = split(els)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == els
|
||||||
|
|
||||||
|
|
||||||
|
def test_two_tables_split_into_two_groups() -> None:
|
||||||
|
# Use different row values so the two tables are not equal
|
||||||
|
t1 = {
|
||||||
|
"tag": "table",
|
||||||
|
"columns": [{"tag": "column", "name": "c0", "display_name": "A", "width": "auto"}],
|
||||||
|
"rows": [{"c0": "table-one"}],
|
||||||
|
"page_size": 2,
|
||||||
|
}
|
||||||
|
t2 = {
|
||||||
|
"tag": "table",
|
||||||
|
"columns": [{"tag": "column", "name": "c0", "display_name": "B", "width": "auto"}],
|
||||||
|
"rows": [{"c0": "table-two"}],
|
||||||
|
"page_size": 2,
|
||||||
|
}
|
||||||
|
els = [_md("before"), t1, _md("between"), t2, _md("after")]
|
||||||
|
result = split(els)
|
||||||
|
assert len(result) == 2
|
||||||
|
# First group: text before table-1 + table-1
|
||||||
|
assert t1 in result[0]
|
||||||
|
assert t2 not in result[0]
|
||||||
|
# Second group: text between tables + table-2 + text after
|
||||||
|
assert t2 in result[1]
|
||||||
|
assert t1 not in result[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_tables_split_into_three_groups() -> None:
|
||||||
|
tables = [
|
||||||
|
{"tag": "table", "columns": [], "rows": [{"c0": f"t{i}"}], "page_size": 1}
|
||||||
|
for i in range(3)
|
||||||
|
]
|
||||||
|
els = tables[:]
|
||||||
|
result = split(els)
|
||||||
|
assert len(result) == 3
|
||||||
|
for i, group in enumerate(result):
|
||||||
|
assert tables[i] in group
|
||||||
|
|
||||||
|
|
||||||
|
def test_leading_markdown_stays_with_first_table() -> None:
|
||||||
|
intro = _md("intro")
|
||||||
|
t = _table()
|
||||||
|
result = split([intro, t])
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0] == [intro, t]
|
||||||
|
|
||||||
|
|
||||||
|
def test_trailing_markdown_after_second_table() -> None:
|
||||||
|
t1, t2 = _table(), _table()
|
||||||
|
tail = _md("end")
|
||||||
|
result = split([t1, t2, tail])
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[1] == [t2, tail]
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_table_elements_before_first_table_kept_in_first_group() -> None:
|
||||||
|
head = _md("head")
|
||||||
|
t1, t2 = _table(), _table()
|
||||||
|
result = split([head, t1, t2])
|
||||||
|
# head + t1 in group 0; t2 in group 1
|
||||||
|
assert result[0] == [head, t1]
|
||||||
|
assert result[1] == [t2]
|
||||||
@@ -145,3 +145,78 @@ class TestMemoryConsolidationTypeHandling:
|
|||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
provider.chat.assert_not_called()
|
provider.chat.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_arguments_extracts_first_dict(self, tmp_path: Path) -> None:
|
||||||
|
"""Some providers return arguments as a list - extract first element if it's a dict."""
|
||||||
|
store = MemoryStore(tmp_path)
|
||||||
|
provider = AsyncMock()
|
||||||
|
|
||||||
|
# Simulate arguments being a list containing a dict
|
||||||
|
response = LLMResponse(
|
||||||
|
content=None,
|
||||||
|
tool_calls=[
|
||||||
|
ToolCallRequest(
|
||||||
|
id="call_1",
|
||||||
|
name="save_memory",
|
||||||
|
arguments=[{
|
||||||
|
"history_entry": "[2026-01-01] User discussed testing.",
|
||||||
|
"memory_update": "# Memory\nUser likes testing.",
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
provider.chat = AsyncMock(return_value=response)
|
||||||
|
session = _make_session(message_count=60)
|
||||||
|
|
||||||
|
result = await store.consolidate(session, provider, "test-model", memory_window=50)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert "User discussed testing." in store.history_file.read_text()
|
||||||
|
assert "User likes testing." in store.memory_file.read_text()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_arguments_empty_list_returns_false(self, tmp_path: Path) -> None:
|
||||||
|
"""Empty list arguments should return False."""
|
||||||
|
store = MemoryStore(tmp_path)
|
||||||
|
provider = AsyncMock()
|
||||||
|
|
||||||
|
response = LLMResponse(
|
||||||
|
content=None,
|
||||||
|
tool_calls=[
|
||||||
|
ToolCallRequest(
|
||||||
|
id="call_1",
|
||||||
|
name="save_memory",
|
||||||
|
arguments=[],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
provider.chat = AsyncMock(return_value=response)
|
||||||
|
session = _make_session(message_count=60)
|
||||||
|
|
||||||
|
result = await store.consolidate(session, provider, "test-model", memory_window=50)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_arguments_non_dict_content_returns_false(self, tmp_path: Path) -> None:
|
||||||
|
"""List with non-dict content should return False."""
|
||||||
|
store = MemoryStore(tmp_path)
|
||||||
|
provider = AsyncMock()
|
||||||
|
|
||||||
|
response = LLMResponse(
|
||||||
|
content=None,
|
||||||
|
tool_calls=[
|
||||||
|
ToolCallRequest(
|
||||||
|
id="call_1",
|
||||||
|
name="save_memory",
|
||||||
|
arguments=["string", "content"],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
provider.chat = AsyncMock(return_value=response)
|
||||||
|
session = _make_session(message_count=60)
|
||||||
|
|
||||||
|
result = await store.consolidate(session, provider, "test-model", memory_window=50)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
|||||||
Reference in New Issue
Block a user