Merge remote-tracking branch 'origin/main' into pr-1239
This commit is contained in:
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
⚡️ Delivers core agent functionality in just **~4,000** lines of code — **99% smaller** than Clawdbot's 430k+ lines.
|
||||||
|
|
||||||
📏 Real-time line count: **3,932 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
📏 Real-time line count: **3,922 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
|
|||||||
@@ -692,7 +692,7 @@ class FeishuChannel(BaseChannel):
|
|||||||
msg_type = message.message_type
|
msg_type = message.message_type
|
||||||
|
|
||||||
# Add reaction
|
# Add reaction
|
||||||
await self._add_reaction(message_id, "THUMBSUP")
|
await self._add_reaction(message_id, self.config.react_emoji)
|
||||||
|
|
||||||
# Parse content
|
# Parse content
|
||||||
content_parts = []
|
content_parts = []
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ class TelegramChannel(BaseChannel):
|
|||||||
self._app: Application | None = None
|
self._app: Application | None = None
|
||||||
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
self._chat_ids: dict[str, int] = {} # Map sender_id to chat_id for replies
|
||||||
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
self._typing_tasks: dict[str, asyncio.Task] = {} # chat_id -> typing loop task
|
||||||
|
self._media_group_buffers: dict[str, dict] = {}
|
||||||
|
self._media_group_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Start the Telegram bot with long polling."""
|
"""Start the Telegram bot with long polling."""
|
||||||
@@ -191,6 +193,11 @@ class TelegramChannel(BaseChannel):
|
|||||||
# Cancel all typing indicators
|
# Cancel all typing indicators
|
||||||
for chat_id in list(self._typing_tasks):
|
for chat_id in list(self._typing_tasks):
|
||||||
self._stop_typing(chat_id)
|
self._stop_typing(chat_id)
|
||||||
|
|
||||||
|
for task in self._media_group_tasks.values():
|
||||||
|
task.cancel()
|
||||||
|
self._media_group_tasks.clear()
|
||||||
|
self._media_group_buffers.clear()
|
||||||
|
|
||||||
if self._app:
|
if self._app:
|
||||||
logger.info("Stopping Telegram bot...")
|
logger.info("Stopping Telegram bot...")
|
||||||
@@ -399,6 +406,28 @@ class TelegramChannel(BaseChannel):
|
|||||||
logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
|
logger.debug("Telegram message from {}: {}...", sender_id, content[:50])
|
||||||
|
|
||||||
str_chat_id = str(chat_id)
|
str_chat_id = str(chat_id)
|
||||||
|
|
||||||
|
# Telegram media groups: buffer briefly, forward as one aggregated turn.
|
||||||
|
if media_group_id := getattr(message, "media_group_id", None):
|
||||||
|
key = f"{str_chat_id}:{media_group_id}"
|
||||||
|
if key not in self._media_group_buffers:
|
||||||
|
self._media_group_buffers[key] = {
|
||||||
|
"sender_id": sender_id, "chat_id": str_chat_id,
|
||||||
|
"contents": [], "media": [],
|
||||||
|
"metadata": {
|
||||||
|
"message_id": message.message_id, "user_id": user.id,
|
||||||
|
"username": user.username, "first_name": user.first_name,
|
||||||
|
"is_group": message.chat.type != "private",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self._start_typing(str_chat_id)
|
||||||
|
buf = self._media_group_buffers[key]
|
||||||
|
if content and content != "[empty message]":
|
||||||
|
buf["contents"].append(content)
|
||||||
|
buf["media"].extend(media_paths)
|
||||||
|
if key not in self._media_group_tasks:
|
||||||
|
self._media_group_tasks[key] = asyncio.create_task(self._flush_media_group(key))
|
||||||
|
return
|
||||||
|
|
||||||
# Start typing indicator before processing
|
# Start typing indicator before processing
|
||||||
self._start_typing(str_chat_id)
|
self._start_typing(str_chat_id)
|
||||||
@@ -418,6 +447,21 @@ class TelegramChannel(BaseChannel):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _flush_media_group(self, key: str) -> None:
|
||||||
|
"""Wait briefly, then forward buffered media-group as one turn."""
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(0.6)
|
||||||
|
if not (buf := self._media_group_buffers.pop(key, None)):
|
||||||
|
return
|
||||||
|
content = "\n".join(buf["contents"]) or "[empty message]"
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=buf["sender_id"], chat_id=buf["chat_id"],
|
||||||
|
content=content, media=list(dict.fromkeys(buf["media"])),
|
||||||
|
metadata=buf["metadata"],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._media_group_tasks.pop(key, None)
|
||||||
|
|
||||||
def _start_typing(self, chat_id: str) -> None:
|
def _start_typing(self, chat_id: str) -> None:
|
||||||
"""Start sending 'typing...' indicator for a chat."""
|
"""Start sending 'typing...' indicator for a chat."""
|
||||||
# Cancel any existing typing task for this chat
|
# Cancel any existing typing task for this chat
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from prompt_toolkit.patch_stdout import patch_stdout
|
|||||||
|
|
||||||
from nanobot import __version__, __logo__
|
from nanobot import __version__, __logo__
|
||||||
from nanobot.config.schema import Config
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.utils.helpers import sync_workspace_templates
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="nanobot",
|
name="nanobot",
|
||||||
@@ -185,8 +186,7 @@ def onboard():
|
|||||||
workspace.mkdir(parents=True, exist_ok=True)
|
workspace.mkdir(parents=True, exist_ok=True)
|
||||||
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
console.print(f"[green]✓[/green] Created workspace at {workspace}")
|
||||||
|
|
||||||
# Create default bootstrap files
|
sync_workspace_templates(workspace)
|
||||||
_create_workspace_templates(workspace)
|
|
||||||
|
|
||||||
console.print(f"\n{__logo__} nanobot is ready!")
|
console.print(f"\n{__logo__} nanobot is ready!")
|
||||||
console.print("\nNext steps:")
|
console.print("\nNext steps:")
|
||||||
@@ -198,36 +198,6 @@ def onboard():
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _create_workspace_templates(workspace: Path):
|
|
||||||
"""Create default workspace template files from bundled templates."""
|
|
||||||
from importlib.resources import files as pkg_files
|
|
||||||
|
|
||||||
templates_dir = pkg_files("nanobot") / "templates"
|
|
||||||
|
|
||||||
for item in templates_dir.iterdir():
|
|
||||||
if not item.name.endswith(".md"):
|
|
||||||
continue
|
|
||||||
dest = workspace / item.name
|
|
||||||
if not dest.exists():
|
|
||||||
dest.write_text(item.read_text(encoding="utf-8"), encoding="utf-8")
|
|
||||||
console.print(f" [dim]Created {item.name}[/dim]")
|
|
||||||
|
|
||||||
memory_dir = workspace / "memory"
|
|
||||||
memory_dir.mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
memory_template = templates_dir / "memory" / "MEMORY.md"
|
|
||||||
memory_file = memory_dir / "MEMORY.md"
|
|
||||||
if not memory_file.exists():
|
|
||||||
memory_file.write_text(memory_template.read_text(encoding="utf-8"), encoding="utf-8")
|
|
||||||
console.print(" [dim]Created memory/MEMORY.md[/dim]")
|
|
||||||
|
|
||||||
history_file = memory_dir / "HISTORY.md"
|
|
||||||
if not history_file.exists():
|
|
||||||
history_file.write_text("", encoding="utf-8")
|
|
||||||
console.print(" [dim]Created memory/HISTORY.md[/dim]")
|
|
||||||
|
|
||||||
(workspace / "skills").mkdir(exist_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _make_provider(config: Config):
|
def _make_provider(config: Config):
|
||||||
"""Create the appropriate LLM provider from config."""
|
"""Create the appropriate LLM provider from config."""
|
||||||
@@ -294,6 +264,7 @@ def gateway(
|
|||||||
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
console.print(f"{__logo__} Starting nanobot gateway on port {port}...")
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
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)
|
||||||
@@ -447,6 +418,7 @@ def agent(
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
sync_workspace_templates(config.workspace_path)
|
||||||
|
|
||||||
bus = MessageBus()
|
bus = MessageBus()
|
||||||
provider = _make_provider(config)
|
provider = _make_provider(config)
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
class DingTalkConfig(Base):
|
class DingTalkConfig(Base):
|
||||||
|
|||||||
@@ -1,79 +1,67 @@
|
|||||||
"""Utility functions for nanobot."""
|
"""Utility functions for nanobot."""
|
||||||
|
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
def ensure_dir(path: Path) -> Path:
|
def ensure_dir(path: Path) -> Path:
|
||||||
"""Ensure a directory exists, creating it if necessary."""
|
"""Ensure directory exists, return it."""
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def get_data_path() -> Path:
|
def get_data_path() -> Path:
|
||||||
"""Get the nanobot data directory (~/.nanobot)."""
|
"""~/.nanobot data directory."""
|
||||||
return ensure_dir(Path.home() / ".nanobot")
|
return ensure_dir(Path.home() / ".nanobot")
|
||||||
|
|
||||||
|
|
||||||
def get_workspace_path(workspace: str | None = None) -> Path:
|
def get_workspace_path(workspace: str | None = None) -> Path:
|
||||||
"""
|
"""Resolve and ensure workspace path. Defaults to ~/.nanobot/workspace."""
|
||||||
Get the workspace path.
|
path = Path(workspace).expanduser() if workspace else Path.home() / ".nanobot" / "workspace"
|
||||||
|
|
||||||
Args:
|
|
||||||
workspace: Optional workspace path. Defaults to ~/.nanobot/workspace.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Expanded and ensured workspace path.
|
|
||||||
"""
|
|
||||||
if workspace:
|
|
||||||
path = Path(workspace).expanduser()
|
|
||||||
else:
|
|
||||||
path = Path.home() / ".nanobot" / "workspace"
|
|
||||||
return ensure_dir(path)
|
return ensure_dir(path)
|
||||||
|
|
||||||
|
|
||||||
def get_sessions_path() -> Path:
|
|
||||||
"""Get the sessions storage directory."""
|
|
||||||
return ensure_dir(get_data_path() / "sessions")
|
|
||||||
|
|
||||||
|
|
||||||
def get_skills_path(workspace: Path | None = None) -> Path:
|
|
||||||
"""Get the skills directory within the workspace."""
|
|
||||||
ws = workspace or get_workspace_path()
|
|
||||||
return ensure_dir(ws / "skills")
|
|
||||||
|
|
||||||
|
|
||||||
def timestamp() -> str:
|
def timestamp() -> str:
|
||||||
"""Get current timestamp in ISO format."""
|
"""Current ISO timestamp."""
|
||||||
return datetime.now().isoformat()
|
return datetime.now().isoformat()
|
||||||
|
|
||||||
|
|
||||||
def truncate_string(s: str, max_len: int = 100, suffix: str = "...") -> str:
|
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
|
||||||
"""Truncate a string to max length, adding suffix if truncated."""
|
|
||||||
if len(s) <= max_len:
|
|
||||||
return s
|
|
||||||
return s[: max_len - len(suffix)] + suffix
|
|
||||||
|
|
||||||
|
|
||||||
def safe_filename(name: str) -> str:
|
def safe_filename(name: str) -> str:
|
||||||
"""Convert a string to a safe filename."""
|
"""Replace unsafe path characters with underscores."""
|
||||||
# Replace unsafe characters
|
return _UNSAFE_CHARS.sub("_", name).strip()
|
||||||
unsafe = '<>:"/\\|?*'
|
|
||||||
for char in unsafe:
|
|
||||||
name = name.replace(char, "_")
|
|
||||||
return name.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_session_key(key: str) -> tuple[str, str]:
|
def sync_workspace_templates(workspace: Path, silent: bool = False) -> list[str]:
|
||||||
"""
|
"""Sync bundled templates to workspace. Only creates missing files."""
|
||||||
Parse a session key into channel and chat_id.
|
from importlib.resources import files as pkg_files
|
||||||
|
try:
|
||||||
Args:
|
tpl = pkg_files("nanobot") / "templates"
|
||||||
key: Session key in format "channel:chat_id"
|
except Exception:
|
||||||
|
return []
|
||||||
Returns:
|
if not tpl.is_dir():
|
||||||
Tuple of (channel, chat_id)
|
return []
|
||||||
"""
|
|
||||||
parts = key.split(":", 1)
|
added: list[str] = []
|
||||||
if len(parts) != 2:
|
|
||||||
raise ValueError(f"Invalid session key: {key}")
|
def _write(src, dest: Path):
|
||||||
return parts[0], parts[1]
|
if dest.exists():
|
||||||
|
return
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_text(src.read_text(encoding="utf-8") if src else "", encoding="utf-8")
|
||||||
|
added.append(str(dest.relative_to(workspace)))
|
||||||
|
|
||||||
|
for item in tpl.iterdir():
|
||||||
|
if item.name.endswith(".md"):
|
||||||
|
_write(item, workspace / item.name)
|
||||||
|
_write(tpl / "memory" / "MEMORY.md", workspace / "memory" / "MEMORY.md")
|
||||||
|
_write(None, workspace / "memory" / "HISTORY.md")
|
||||||
|
(workspace / "skills").mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
if added and not silent:
|
||||||
|
from rich.console import Console
|
||||||
|
for name in added:
|
||||||
|
Console().print(f" [dim]Created {name}[/dim]")
|
||||||
|
return added
|
||||||
|
|||||||
Reference in New Issue
Block a user