resolve merge conflict in README
This commit is contained in:
commit
96e6f31387
72
README.md
72
README.md
@ -16,24 +16,18 @@
|
|||||||
|
|
||||||
⚡️ 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,390 lines** (run `bash core_agent_lines.sh` to verify anytime)
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
|
|
||||||
<<<<<<< main
|
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
|
||||||
- **2025-02-03** 🔒 Security audit completed! See [SECURITY_AUDIT.md](./SECURITY_AUDIT.md) and [SECURITY.md](./SECURITY.md) for details.
|
- **2026-02-04** 🚀 Released v0.1.3.post4 with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
||||||
- **2025-02-01** 🎉 nanobot launched! Welcome to try 🐈 nanobot!
|
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
|
||||||
|
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
|
||||||
> [!IMPORTANT]
|
|
||||||
> **Security Notice**: If you're using nanobot in production, please review [SECURITY.md](./SECURITY.md) for security best practices.
|
|
||||||
> Key actions: Configure `allowFrom` lists, secure your API keys, and keep dependencies updated.
|
|
||||||
=======
|
|
||||||
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and better scheduled tasks support!
|
|
||||||
- **2026-02-04** 🚀 v0.1.3.post4 released with multi-provider & Docker support! Check [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
|
|
||||||
- **2026-02-02** 🎉 nanobot launched! Welcome to try 🐈 nanobot!
|
|
||||||
>>>>>>> main
|
|
||||||
|
|
||||||
## Key Features of nanobot:
|
## Key Features of nanobot:
|
||||||
|
|
||||||
🪶 **Ultra-Lightweight**: Just ~4,000 lines of code — 99% smaller than Clawdbot - core functionality.
|
🪶 **Ultra-Lightweight**: Just ~3,400 lines of core agent code — 99% smaller than Clawdbot.
|
||||||
|
|
||||||
🔬 **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research.
|
🔬 **Research-Ready**: Clean, readable code that's easy to understand, modify, and extend for research.
|
||||||
|
|
||||||
@ -177,11 +171,12 @@ nanobot agent -m "Hello from my local LLM!"
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your nanobot through Telegram, WhatsApp, or Feishu — anytime, anywhere.
|
Talk to your nanobot through Telegram, Discord, WhatsApp, or Feishu — anytime, anywhere.
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
| **Telegram** | Easy (just a token) |
|
| **Telegram** | Easy (just a token) |
|
||||||
|
| **Discord** | Easy (bot token + intents) |
|
||||||
| **WhatsApp** | Medium (scan QR) |
|
| **WhatsApp** | Medium (scan QR) |
|
||||||
| **Feishu** | Medium (app credentials) |
|
| **Feishu** | Medium (app credentials) |
|
||||||
|
|
||||||
@ -217,6 +212,50 @@ nanobot gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Discord</b></summary>
|
||||||
|
|
||||||
|
**1. Create a bot**
|
||||||
|
- Go to https://discord.com/developers/applications
|
||||||
|
- Create an application → Bot → Add Bot
|
||||||
|
- Copy the bot token
|
||||||
|
|
||||||
|
**2. Enable intents**
|
||||||
|
- In the Bot settings, enable **MESSAGE CONTENT INTENT**
|
||||||
|
- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||||
|
|
||||||
|
**3. Get your User ID**
|
||||||
|
- Discord Settings → Advanced → enable **Developer Mode**
|
||||||
|
- Right-click your avatar → **Copy User ID**
|
||||||
|
|
||||||
|
**4. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"discord": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "YOUR_BOT_TOKEN",
|
||||||
|
"allowFrom": ["YOUR_USER_ID"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**5. Invite the bot**
|
||||||
|
- OAuth2 → URL Generator
|
||||||
|
- Scopes: `bot`
|
||||||
|
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||||
|
- Open the generated invite URL and add the bot to your server
|
||||||
|
|
||||||
|
**6. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>WhatsApp</b></summary>
|
<summary><b>WhatsApp</b></summary>
|
||||||
|
|
||||||
@ -346,6 +385,11 @@ Config file: `~/.nanobot/config.json`
|
|||||||
"token": "123456:ABC...",
|
"token": "123456:ABC...",
|
||||||
"allowFrom": ["123456789"]
|
"allowFrom": ["123456789"]
|
||||||
},
|
},
|
||||||
|
"discord": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
|
"allowFrom": ["YOUR_USER_ID"]
|
||||||
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
|
|||||||
21
core_agent_lines.sh
Executable file
21
core_agent_lines.sh
Executable file
@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Count core agent lines (excluding channels/, cli/, providers/ adapters)
|
||||||
|
cd "$(dirname "$0")" || exit 1
|
||||||
|
|
||||||
|
echo "nanobot core agent line count"
|
||||||
|
echo "================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
for dir in agent agent/tools bus config cron heartbeat session utils; do
|
||||||
|
count=$(find "nanobot/$dir" -maxdepth 1 -name "*.py" -exec cat {} + | wc -l)
|
||||||
|
printf " %-16s %5s lines\n" "$dir/" "$count"
|
||||||
|
done
|
||||||
|
|
||||||
|
root=$(cat nanobot/__init__.py nanobot/__main__.py | wc -l)
|
||||||
|
printf " %-16s %5s lines\n" "(root)" "$root"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
total=$(find nanobot -name "*.py" ! -path "*/channels/*" ! -path "*/cli/*" ! -path "*/providers/*" | xargs cat | wc -l)
|
||||||
|
echo " Core total: $total lines"
|
||||||
|
echo ""
|
||||||
|
echo " (excludes: channels/, cli/, providers/)"
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import platform
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -74,6 +75,8 @@ Skills with available="false" need dependencies installed first - you can try in
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)")
|
||||||
workspace_path = str(self.workspace.expanduser().resolve())
|
workspace_path = str(self.workspace.expanduser().resolve())
|
||||||
|
system = platform.system()
|
||||||
|
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
|
||||||
|
|
||||||
return f"""# nanobot 🐈
|
return f"""# nanobot 🐈
|
||||||
|
|
||||||
@ -87,6 +90,9 @@ You are nanobot, a helpful AI assistant. You have access to tools that allow you
|
|||||||
## Current Time
|
## Current Time
|
||||||
{now}
|
{now}
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
{runtime}
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: {workspace_path}
|
Your workspace is at: {workspace_path}
|
||||||
- Memory files: {workspace_path}/memory/MEMORY.md
|
- Memory files: {workspace_path}/memory/MEMORY.md
|
||||||
|
|||||||
261
nanobot/channels/discord.py
Normal file
261
nanobot/channels/discord.py
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
"""Discord channel implementation using Discord Gateway websocket."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.bus.events import OutboundMessage
|
||||||
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.channels.base import BaseChannel
|
||||||
|
from nanobot.config.schema import DiscordConfig
|
||||||
|
|
||||||
|
|
||||||
|
DISCORD_API_BASE = "https://discord.com/api/v10"
|
||||||
|
MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 # 20MB
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordChannel(BaseChannel):
|
||||||
|
"""Discord channel using Gateway websocket."""
|
||||||
|
|
||||||
|
name = "discord"
|
||||||
|
|
||||||
|
def __init__(self, config: DiscordConfig, bus: MessageBus):
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self.config: DiscordConfig = config
|
||||||
|
self._ws: websockets.WebSocketClientProtocol | None = None
|
||||||
|
self._seq: int | None = None
|
||||||
|
self._heartbeat_task: asyncio.Task | None = None
|
||||||
|
self._typing_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
self._http: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Start the Discord gateway connection."""
|
||||||
|
if not self.config.token:
|
||||||
|
logger.error("Discord bot token not configured")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._http = httpx.AsyncClient(timeout=30.0)
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
logger.info("Connecting to Discord gateway...")
|
||||||
|
async with websockets.connect(self.config.gateway_url) as ws:
|
||||||
|
self._ws = ws
|
||||||
|
await self._gateway_loop()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Discord gateway error: {e}")
|
||||||
|
if self._running:
|
||||||
|
logger.info("Reconnecting to Discord gateway in 5 seconds...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop the Discord channel."""
|
||||||
|
self._running = False
|
||||||
|
if self._heartbeat_task:
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
self._heartbeat_task = None
|
||||||
|
for task in self._typing_tasks.values():
|
||||||
|
task.cancel()
|
||||||
|
self._typing_tasks.clear()
|
||||||
|
if self._ws:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._http:
|
||||||
|
await self._http.aclose()
|
||||||
|
self._http = None
|
||||||
|
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
"""Send a message through Discord REST API."""
|
||||||
|
if not self._http:
|
||||||
|
logger.warning("Discord HTTP client not initialized")
|
||||||
|
return
|
||||||
|
|
||||||
|
url = f"{DISCORD_API_BASE}/channels/{msg.chat_id}/messages"
|
||||||
|
payload: dict[str, Any] = {"content": msg.content}
|
||||||
|
|
||||||
|
if msg.reply_to:
|
||||||
|
payload["message_reference"] = {"message_id": msg.reply_to}
|
||||||
|
payload["allowed_mentions"] = {"replied_user": False}
|
||||||
|
|
||||||
|
headers = {"Authorization": f"Bot {self.config.token}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
response = await self._http.post(url, headers=headers, json=payload)
|
||||||
|
if response.status_code == 429:
|
||||||
|
data = response.json()
|
||||||
|
retry_after = float(data.get("retry_after", 1.0))
|
||||||
|
logger.warning(f"Discord rate limited, retrying in {retry_after}s")
|
||||||
|
await asyncio.sleep(retry_after)
|
||||||
|
continue
|
||||||
|
response.raise_for_status()
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == 2:
|
||||||
|
logger.error(f"Error sending Discord message: {e}")
|
||||||
|
else:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
finally:
|
||||||
|
await self._stop_typing(msg.chat_id)
|
||||||
|
|
||||||
|
async def _gateway_loop(self) -> None:
|
||||||
|
"""Main gateway loop: identify, heartbeat, dispatch events."""
|
||||||
|
if not self._ws:
|
||||||
|
return
|
||||||
|
|
||||||
|
async for raw in self._ws:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.warning(f"Invalid JSON from Discord gateway: {raw[:100]}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
op = data.get("op")
|
||||||
|
event_type = data.get("t")
|
||||||
|
seq = data.get("s")
|
||||||
|
payload = data.get("d")
|
||||||
|
|
||||||
|
if seq is not None:
|
||||||
|
self._seq = seq
|
||||||
|
|
||||||
|
if op == 10:
|
||||||
|
# HELLO: start heartbeat and identify
|
||||||
|
interval_ms = payload.get("heartbeat_interval", 45000)
|
||||||
|
await self._start_heartbeat(interval_ms / 1000)
|
||||||
|
await self._identify()
|
||||||
|
elif op == 0 and event_type == "READY":
|
||||||
|
logger.info("Discord gateway READY")
|
||||||
|
elif op == 0 and event_type == "MESSAGE_CREATE":
|
||||||
|
await self._handle_message_create(payload)
|
||||||
|
elif op == 7:
|
||||||
|
# RECONNECT: exit loop to reconnect
|
||||||
|
logger.info("Discord gateway requested reconnect")
|
||||||
|
break
|
||||||
|
elif op == 9:
|
||||||
|
# INVALID_SESSION: reconnect
|
||||||
|
logger.warning("Discord gateway invalid session")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def _identify(self) -> None:
|
||||||
|
"""Send IDENTIFY payload."""
|
||||||
|
if not self._ws:
|
||||||
|
return
|
||||||
|
|
||||||
|
identify = {
|
||||||
|
"op": 2,
|
||||||
|
"d": {
|
||||||
|
"token": self.config.token,
|
||||||
|
"intents": self.config.intents,
|
||||||
|
"properties": {
|
||||||
|
"os": "nanobot",
|
||||||
|
"browser": "nanobot",
|
||||||
|
"device": "nanobot",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await self._ws.send(json.dumps(identify))
|
||||||
|
|
||||||
|
async def _start_heartbeat(self, interval_s: float) -> None:
|
||||||
|
"""Start or restart the heartbeat loop."""
|
||||||
|
if self._heartbeat_task:
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
|
||||||
|
async def heartbeat_loop() -> None:
|
||||||
|
while self._running and self._ws:
|
||||||
|
payload = {"op": 1, "d": self._seq}
|
||||||
|
try:
|
||||||
|
await self._ws.send(json.dumps(payload))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Discord heartbeat failed: {e}")
|
||||||
|
break
|
||||||
|
await asyncio.sleep(interval_s)
|
||||||
|
|
||||||
|
self._heartbeat_task = asyncio.create_task(heartbeat_loop())
|
||||||
|
|
||||||
|
async def _handle_message_create(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Handle incoming Discord messages."""
|
||||||
|
author = payload.get("author") or {}
|
||||||
|
if author.get("bot"):
|
||||||
|
return
|
||||||
|
|
||||||
|
sender_id = str(author.get("id", ""))
|
||||||
|
channel_id = str(payload.get("channel_id", ""))
|
||||||
|
content = payload.get("content") or ""
|
||||||
|
|
||||||
|
if not sender_id or not channel_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.is_allowed(sender_id):
|
||||||
|
return
|
||||||
|
|
||||||
|
content_parts = [content] if content else []
|
||||||
|
media_paths: list[str] = []
|
||||||
|
media_dir = Path.home() / ".nanobot" / "media"
|
||||||
|
|
||||||
|
for attachment in payload.get("attachments") or []:
|
||||||
|
url = attachment.get("url")
|
||||||
|
filename = attachment.get("filename") or "attachment"
|
||||||
|
size = attachment.get("size") or 0
|
||||||
|
if not url or not self._http:
|
||||||
|
continue
|
||||||
|
if size and size > MAX_ATTACHMENT_BYTES:
|
||||||
|
content_parts.append(f"[attachment: {filename} - too large]")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
media_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
file_path = media_dir / f"{attachment.get('id', 'file')}_{filename.replace('/', '_')}"
|
||||||
|
resp = await self._http.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
file_path.write_bytes(resp.content)
|
||||||
|
media_paths.append(str(file_path))
|
||||||
|
content_parts.append(f"[attachment: {file_path}]")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to download Discord attachment: {e}")
|
||||||
|
content_parts.append(f"[attachment: {filename} - download failed]")
|
||||||
|
|
||||||
|
reply_to = (payload.get("referenced_message") or {}).get("id")
|
||||||
|
|
||||||
|
await self._start_typing(channel_id)
|
||||||
|
|
||||||
|
await self._handle_message(
|
||||||
|
sender_id=sender_id,
|
||||||
|
chat_id=channel_id,
|
||||||
|
content="\n".join(p for p in content_parts if p) or "[empty message]",
|
||||||
|
media=media_paths,
|
||||||
|
metadata={
|
||||||
|
"message_id": str(payload.get("id", "")),
|
||||||
|
"guild_id": payload.get("guild_id"),
|
||||||
|
"reply_to": reply_to,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _start_typing(self, channel_id: str) -> None:
|
||||||
|
"""Start periodic typing indicator for a channel."""
|
||||||
|
await self._stop_typing(channel_id)
|
||||||
|
|
||||||
|
async def typing_loop() -> None:
|
||||||
|
url = f"{DISCORD_API_BASE}/channels/{channel_id}/typing"
|
||||||
|
headers = {"Authorization": f"Bot {self.config.token}"}
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._http.post(url, headers=headers)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await asyncio.sleep(8)
|
||||||
|
|
||||||
|
self._typing_tasks[channel_id] = asyncio.create_task(typing_loop())
|
||||||
|
|
||||||
|
async def _stop_typing(self, channel_id: str) -> None:
|
||||||
|
"""Stop typing indicator for a channel."""
|
||||||
|
task = self._typing_tasks.pop(channel_id, None)
|
||||||
|
if task:
|
||||||
|
task.cancel()
|
||||||
@ -55,6 +55,17 @@ class ChannelManager:
|
|||||||
logger.info("WhatsApp channel enabled")
|
logger.info("WhatsApp channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"WhatsApp channel not available: {e}")
|
logger.warning(f"WhatsApp channel not available: {e}")
|
||||||
|
|
||||||
|
# Discord channel
|
||||||
|
if self.config.channels.discord.enabled:
|
||||||
|
try:
|
||||||
|
from nanobot.channels.discord import DiscordChannel
|
||||||
|
self.channels["discord"] = DiscordChannel(
|
||||||
|
self.config.channels.discord, self.bus
|
||||||
|
)
|
||||||
|
logger.info("Discord channel enabled")
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning(f"Discord channel not available: {e}")
|
||||||
|
|
||||||
# Feishu channel
|
# Feishu channel
|
||||||
if self.config.channels.feishu.enabled:
|
if self.config.channels.feishu.enabled:
|
||||||
|
|||||||
@ -374,6 +374,13 @@ def channels_status():
|
|||||||
wa.bridge_url
|
wa.bridge_url
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dc = config.channels.discord
|
||||||
|
table.add_row(
|
||||||
|
"Discord",
|
||||||
|
"✓" if dc.enabled else "✗",
|
||||||
|
dc.gateway_url
|
||||||
|
)
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
tg = config.channels.telegram
|
tg = config.channels.telegram
|
||||||
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
tg_config = f"token: {tg.token[:10]}..." if tg.token else "[dim]not configured[/dim]"
|
||||||
|
|||||||
@ -30,10 +30,20 @@ class FeishuConfig(BaseModel):
|
|||||||
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
allow_from: list[str] = Field(default_factory=list) # Allowed user open_ids
|
||||||
|
|
||||||
|
|
||||||
|
class DiscordConfig(BaseModel):
|
||||||
|
"""Discord channel configuration."""
|
||||||
|
enabled: bool = False
|
||||||
|
token: str = "" # Bot token from Discord Developer Portal
|
||||||
|
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs
|
||||||
|
gateway_url: str = "wss://gateway.discord.gg/?v=10&encoding=json"
|
||||||
|
intents: int = 37377 # GUILDS + GUILD_MESSAGES + DIRECT_MESSAGES + MESSAGE_CONTENT
|
||||||
|
|
||||||
|
|
||||||
class ChannelsConfig(BaseModel):
|
class ChannelsConfig(BaseModel):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels."""
|
||||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||||
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
|
||||||
|
discord: DiscordConfig = Field(default_factory=DiscordConfig)
|
||||||
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
||||||
|
|
||||||
|
|
||||||
@ -67,6 +77,7 @@ class ProvidersConfig(BaseModel):
|
|||||||
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
zhipu: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
vllm: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
|
gemini: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
|
moonshot: ProviderConfig = Field(default_factory=ProviderConfig)
|
||||||
|
|
||||||
|
|
||||||
class GatewayConfig(BaseModel):
|
class GatewayConfig(BaseModel):
|
||||||
@ -111,27 +122,57 @@ 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 get_api_key(self) -> str | None:
|
def _match_provider(self, model: str | None = None) -> ProviderConfig | None:
|
||||||
"""Get API key in priority order: OpenRouter > DeepSeek > Anthropic > OpenAI > Gemini > Zhipu > Groq > vLLM."""
|
"""Match a provider based on model name."""
|
||||||
return (
|
model = (model or self.agents.defaults.model).lower()
|
||||||
self.providers.openrouter.api_key or
|
# Map of keywords to provider configs
|
||||||
self.providers.deepseek.api_key or
|
providers = {
|
||||||
self.providers.anthropic.api_key or
|
"openrouter": self.providers.openrouter,
|
||||||
self.providers.openai.api_key or
|
"deepseek": self.providers.deepseek,
|
||||||
self.providers.gemini.api_key or
|
"anthropic": self.providers.anthropic,
|
||||||
self.providers.zhipu.api_key or
|
"claude": self.providers.anthropic,
|
||||||
self.providers.groq.api_key or
|
"openai": self.providers.openai,
|
||||||
self.providers.vllm.api_key or
|
"gpt": self.providers.openai,
|
||||||
None
|
"gemini": self.providers.gemini,
|
||||||
)
|
"zhipu": self.providers.zhipu,
|
||||||
|
"glm": self.providers.zhipu,
|
||||||
|
"zai": self.providers.zhipu,
|
||||||
|
"groq": self.providers.groq,
|
||||||
|
"moonshot": self.providers.moonshot,
|
||||||
|
"kimi": self.providers.moonshot,
|
||||||
|
"vllm": self.providers.vllm,
|
||||||
|
}
|
||||||
|
for keyword, provider in providers.items():
|
||||||
|
if keyword in model and provider.api_key:
|
||||||
|
return provider
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_api_key(self, model: str | None = None) -> str | None:
|
||||||
|
"""Get API key for the given model (or default model). Falls back to first available key."""
|
||||||
|
# Try matching by model name first
|
||||||
|
matched = self._match_provider(model)
|
||||||
|
if matched:
|
||||||
|
return matched.api_key
|
||||||
|
# Fallback: return first available key
|
||||||
|
for provider in [
|
||||||
|
self.providers.openrouter, self.providers.deepseek,
|
||||||
|
self.providers.anthropic, self.providers.openai,
|
||||||
|
self.providers.gemini, self.providers.zhipu,
|
||||||
|
self.providers.moonshot, self.providers.vllm,
|
||||||
|
self.providers.groq,
|
||||||
|
]:
|
||||||
|
if provider.api_key:
|
||||||
|
return provider.api_key
|
||||||
|
return None
|
||||||
|
|
||||||
def get_api_base(self) -> str | None:
|
def get_api_base(self, model: str | None = None) -> str | None:
|
||||||
"""Get API base URL if using OpenRouter, Zhipu or vLLM."""
|
"""Get API base URL based on model name."""
|
||||||
if self.providers.openrouter.api_key:
|
model = (model or self.agents.defaults.model).lower()
|
||||||
|
if "openrouter" in model:
|
||||||
return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1"
|
return self.providers.openrouter.api_base or "https://openrouter.ai/api/v1"
|
||||||
if self.providers.zhipu.api_key:
|
if any(k in model for k in ("zhipu", "glm", "zai")):
|
||||||
return self.providers.zhipu.api_base
|
return self.providers.zhipu.api_base
|
||||||
if self.providers.vllm.api_base:
|
if "vllm" in model:
|
||||||
return self.providers.vllm.api_base
|
return self.providers.vllm.api_base
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@ -55,6 +55,9 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
|
os.environ.setdefault("ZHIPUAI_API_KEY", api_key)
|
||||||
elif "groq" in default_model:
|
elif "groq" in default_model:
|
||||||
os.environ.setdefault("GROQ_API_KEY", api_key)
|
os.environ.setdefault("GROQ_API_KEY", api_key)
|
||||||
|
elif "moonshot" in default_model or "kimi" in default_model:
|
||||||
|
os.environ.setdefault("MOONSHOT_API_KEY", api_key)
|
||||||
|
os.environ.setdefault("MOONSHOT_API_BASE", api_base or "https://api.moonshot.cn/v1")
|
||||||
|
|
||||||
if api_base:
|
if api_base:
|
||||||
litellm.api_base = api_base
|
litellm.api_base = api_base
|
||||||
@ -97,16 +100,26 @@ class LiteLLMProvider(LLMProvider):
|
|||||||
model.startswith("openrouter/")
|
model.startswith("openrouter/")
|
||||||
):
|
):
|
||||||
model = f"zai/{model}"
|
model = f"zai/{model}"
|
||||||
|
|
||||||
|
# For Moonshot/Kimi, ensure moonshot/ prefix (before vLLM check)
|
||||||
|
if ("moonshot" in model.lower() or "kimi" in model.lower()) and not (
|
||||||
|
model.startswith("moonshot/") or model.startswith("openrouter/")
|
||||||
|
):
|
||||||
|
model = f"moonshot/{model}"
|
||||||
|
|
||||||
|
# For Gemini, ensure gemini/ prefix if not already present
|
||||||
|
if "gemini" in model.lower() and not model.startswith("gemini/"):
|
||||||
|
model = f"gemini/{model}"
|
||||||
|
|
||||||
# For vLLM, use hosted_vllm/ prefix per LiteLLM docs
|
# For vLLM, use hosted_vllm/ prefix per LiteLLM docs
|
||||||
# Convert openai/ prefix to hosted_vllm/ if user specified it
|
# Convert openai/ prefix to hosted_vllm/ if user specified it
|
||||||
if self.is_vllm:
|
if self.is_vllm:
|
||||||
model = f"hosted_vllm/{model}"
|
model = f"hosted_vllm/{model}"
|
||||||
|
|
||||||
# For Gemini, ensure gemini/ prefix if not already present
|
# kimi-k2.5 only supports temperature=1.0
|
||||||
if "gemini" in model.lower() and not model.startswith("gemini/"):
|
if "kimi-k2.5" in model.lower():
|
||||||
model = f"gemini/{model}"
|
temperature = 1.0
|
||||||
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"model": model,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
|
|||||||
@ -29,12 +29,10 @@ dependencies = [
|
|||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
"croniter>=2.0.0",
|
"croniter>=2.0.0",
|
||||||
"python-telegram-bot>=21.0",
|
"python-telegram-bot>=21.0",
|
||||||
|
"lark-oapi>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
feishu = [
|
|
||||||
"lark-oapi>=1.0.0",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0.0",
|
"pytest>=7.0.0",
|
||||||
"pytest-asyncio>=0.21.0",
|
"pytest-asyncio>=0.21.0",
|
||||||
|
|||||||
1
test_docker.sh → tests/test_docker.sh
Executable file → Normal file
1
test_docker.sh → tests/test_docker.sh
Executable file → Normal file
@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
|
|
||||||
IMAGE_NAME="nanobot-test"
|
IMAGE_NAME="nanobot-test"
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user