diff --git a/.gitignore b/.gitignore
index 55338f7..36dbfc2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,4 +17,5 @@ docs/
__pycache__/
poetry.lock
.pytest_cache/
-tests/
\ No newline at end of file
+tests/
+botpy.log
diff --git a/README.md b/README.md
index 7bf98fd..0c74e17 100644
--- a/README.md
+++ b/README.md
@@ -16,10 +16,12 @@
โก๏ธ Delivers core agent functionality in just **~4,000** lines of code โ **99% smaller** than Clawdbot's 430k+ lines.
-๐ Real-time line count: **3,479 lines** (run `bash core_agent_lines.sh` to verify anytime)
+๐ Real-time line count: **3,510 lines** (run `bash core_agent_lines.sh` to verify anytime)
## ๐ข News
+- **2026-02-10** ๐ Released v0.1.3.post6 with multiple improvements! Check the [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
+- **2026-02-09** ๐ฌ Added Slack, Email, and QQ support โ nanobot now supports multiple chat platforms!
- **2026-02-08** ๐ง Refactored Providersโadding a new LLM provider now takes just 2 simple steps! Check [here](#providers).
- **2026-02-07** ๐ Released v0.1.3.post5 with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** โจ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
@@ -166,7 +168,7 @@ nanobot agent -m "Hello from my local LLM!"
## ๐ฌ Chat Apps
-Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, or Email โ anytime, anywhere.
+Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTalk, Slack, Email, or QQ โ anytime, anywhere.
| Channel | Setup |
|---------|-------|
@@ -176,7 +178,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTa
| **Feishu** | Medium (app credentials) |
| **Mochat** | Medium (claw token + websocket) |
| **DingTalk** | Medium (app credentials) |
+| **Slack** | Medium (bot + app tokens) |
| **Email** | Medium (IMAP/SMTP credentials) |
+| **QQ** | Easy (app credentials) |
Telegram (Recommended)
@@ -200,7 +204,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, Mochat, DingTa
}
```
-> Get your user ID from `@userinfobot` on Telegram.
+> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`.
+> Copy this value **without the `@` symbol** and paste it into the config file.
+
**3. Run**
@@ -378,6 +384,49 @@ nanobot gateway
+
+QQ (QQๅ่)
+
+Uses **botpy SDK** with WebSocket โ no public IP required. Currently supports **private messages only**.
+
+**1. Register & create bot**
+- Visit [QQ Open Platform](https://q.qq.com) โ Register as a developer (personal or enterprise)
+- Create a new bot application
+- Go to **ๅผๅ่ฎพ็ฝฎ (Developer Settings)** โ copy **AppID** and **AppSecret**
+
+**2. Set up sandbox for testing**
+- In the bot management console, find **ๆฒ็ฎฑ้
็ฝฎ (Sandbox Config)**
+- Under **ๅจๆถๆฏๅ่กจ้
็ฝฎ**, click **ๆทปๅ ๆๅ** and add your own QQ number
+- Once added, scan the bot's QR code with mobile QQ โ open the bot profile โ tap "ๅๆถๆฏ" to start chatting
+
+**3. Configure**
+
+> - `allowFrom`: Leave empty for public access, or add user openids to restrict. You can find openids in the nanobot logs when a user messages the bot.
+> - For production: submit a review in the bot console and publish. See [QQ Bot Docs](https://bot.q.qq.com/wiki/) for the full publishing flow.
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "appId": "YOUR_APP_ID",
+ "secret": "YOUR_APP_SECRET",
+ "allowFrom": []
+ }
+ }
+}
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+Now send a message to the bot from QQ โ it should respond!
+
+
+
DingTalk (้้)
@@ -417,20 +466,67 @@ nanobot gateway
+
+Slack
+
+Uses **Socket Mode** โ no public URL required.
+
+**1. Create a Slack app**
+- Go to [Slack API](https://api.slack.com/apps) โ **Create New App** โ "From scratch"
+- Pick a name and select your workspace
+
+**2. Configure the app**
+- **Socket Mode**: Toggle ON โ Generate an **App-Level Token** with `connections:write` scope โ copy it (`xapp-...`)
+- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
+- **Event Subscriptions**: Toggle ON โ Subscribe to bot events: `message.im`, `message.channels`, `app_mention` โ Save Changes
+- **App Home**: Scroll to **Show Tabs** โ Enable **Messages Tab** โ Check **"Allow users to send Slash commands and messages from the messages tab"**
+- **Install App**: Click **Install to Workspace** โ Authorize โ copy the **Bot Token** (`xoxb-...`)
+
+**3. Configure nanobot**
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "botToken": "xoxb-...",
+ "appToken": "xapp-...",
+ "groupPolicy": "mention"
+ }
+ }
+}
+```
+
+**4. Run**
+
+```bash
+nanobot gateway
+```
+
+DM the bot directly or @mention it in a channel โ it should respond!
+
+> [!TIP]
+> - `groupPolicy`: `"mention"` (default โ respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels).
+> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
+
+
+
Email
-Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit consent before accessing mailbox data.
+Give nanobot its own email account. It polls **IMAP** for incoming mail and replies via **SMTP** โ like a personal email assistant.
**1. Get credentials (Gmail example)**
-- Enable 2-Step Verification in Google account security
-- Create an [App Password](https://myaccount.google.com/apppasswords)
+- Create a dedicated Gmail account for your bot (e.g. `my-nanobot@gmail.com`)
+- Enable 2-Step Verification โ Create an [App Password](https://myaccount.google.com/apppasswords)
- Use this app password for both IMAP and SMTP
**2. Configure**
-> [!TIP]
-> Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
+> - `consentGranted` must be `true` to allow mailbox access. This is a safety gate โ set `false` to fully disable.
+> - `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific senders.
+> - `smtpUseTls` and `smtpUseSsl` default to `true` / `false` respectively, which is correct for Gmail (port 587 + STARTTLS). No need to set them explicitly.
+> - Set `"autoReplyEnabled": false` if you only want to read/analyze emails without sending automatic replies.
```json
{
@@ -440,23 +536,19 @@ Uses **IMAP** polling for inbound + **SMTP** for outbound. Requires explicit con
"consentGranted": true,
"imapHost": "imap.gmail.com",
"imapPort": 993,
- "imapUsername": "you@gmail.com",
+ "imapUsername": "my-nanobot@gmail.com",
"imapPassword": "your-app-password",
- "imapUseSsl": true,
"smtpHost": "smtp.gmail.com",
"smtpPort": 587,
- "smtpUsername": "you@gmail.com",
+ "smtpUsername": "my-nanobot@gmail.com",
"smtpPassword": "your-app-password",
- "smtpUseTls": true,
- "fromAddress": "you@gmail.com",
- "allowFrom": ["trusted@example.com"]
+ "fromAddress": "my-nanobot@gmail.com",
+ "allowFrom": ["your-real-email@gmail.com"]
}
}
}
```
-> `consentGranted`: Must be `true` to allow mailbox access. Set to `false` to disable reading and sending entirely.
-> `allowFrom`: Leave empty to accept emails from anyone, or restrict to specific sender addresses.
**3. Run**
@@ -537,7 +629,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
### Security
-> [!TIP]
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
| Option | Default | Description |
@@ -636,13 +727,13 @@ PRs welcome! The codebase is intentionally small and readable. ๐ค
- [ ] **Multi-modal** โ See and hear (images, voice, video)
- [ ] **Long-term memory** โ Never forget important context
- [ ] **Better reasoning** โ Multi-step planning and reflection
-- [ ] **More integrations** โ Slack, calendar, and more
+- [ ] **More integrations** โ Calendar and more
- [ ] **Self-improvement** โ Learn from feedback and mistakes
### Contributors
-
+
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index 72ea86a..b764c3d 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -245,7 +245,8 @@ class AgentLoop:
return OutboundMessage(
channel=msg.channel,
chat_id=msg.chat_id,
- content=final_content
+ content=final_content,
+ metadata=msg.metadata or {}, # Pass through for channel-specific needs (e.g. Slack thread_ts)
)
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index f1d0001..464fa97 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -118,6 +118,29 @@ class ChannelManager:
logger.info("Email channel enabled")
except ImportError as e:
logger.warning(f"Email channel not available: {e}")
+
+ # Slack channel
+ if self.config.channels.slack.enabled:
+ try:
+ from nanobot.channels.slack import SlackChannel
+ self.channels["slack"] = SlackChannel(
+ self.config.channels.slack, self.bus
+ )
+ logger.info("Slack channel enabled")
+ except ImportError as e:
+ logger.warning(f"Slack channel not available: {e}")
+
+ # QQ channel
+ if self.config.channels.qq.enabled:
+ try:
+ from nanobot.channels.qq import QQChannel
+ self.channels["qq"] = QQChannel(
+ self.config.channels.qq,
+ self.bus,
+ )
+ logger.info("QQ channel enabled")
+ except ImportError as e:
+ logger.warning(f"QQ channel not available: {e}")
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
"""Start a channel and log any exceptions."""
diff --git a/nanobot/channels/qq.py b/nanobot/channels/qq.py
new file mode 100644
index 0000000..5964d30
--- /dev/null
+++ b/nanobot/channels/qq.py
@@ -0,0 +1,131 @@
+"""QQ channel implementation using botpy SDK."""
+
+import asyncio
+from collections import deque
+from typing import TYPE_CHECKING
+
+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 QQConfig
+
+try:
+ import botpy
+ from botpy.message import C2CMessage
+
+ QQ_AVAILABLE = True
+except ImportError:
+ QQ_AVAILABLE = False
+ botpy = None
+ C2CMessage = None
+
+if TYPE_CHECKING:
+ from botpy.message import C2CMessage
+
+
+def _make_bot_class(channel: "QQChannel") -> "type[botpy.Client]":
+ """Create a botpy Client subclass bound to the given channel."""
+ intents = botpy.Intents(public_messages=True, direct_message=True)
+
+ class _Bot(botpy.Client):
+ def __init__(self):
+ super().__init__(intents=intents)
+
+ async def on_ready(self):
+ logger.info(f"QQ bot ready: {self.robot.name}")
+
+ async def on_c2c_message_create(self, message: "C2CMessage"):
+ await channel._on_message(message)
+
+ async def on_direct_message_create(self, message):
+ await channel._on_message(message)
+
+ return _Bot
+
+
+class QQChannel(BaseChannel):
+ """QQ channel using botpy SDK with WebSocket connection."""
+
+ name = "qq"
+
+ def __init__(self, config: QQConfig, bus: MessageBus):
+ super().__init__(config, bus)
+ self.config: QQConfig = config
+ self._client: "botpy.Client | None" = None
+ self._processed_ids: deque = deque(maxlen=1000)
+ self._bot_task: asyncio.Task | None = None
+
+ async def start(self) -> None:
+ """Start the QQ bot."""
+ if not QQ_AVAILABLE:
+ logger.error("QQ SDK not installed. Run: pip install qq-botpy")
+ return
+
+ if not self.config.app_id or not self.config.secret:
+ logger.error("QQ app_id and secret not configured")
+ return
+
+ self._running = True
+ BotClass = _make_bot_class(self)
+ self._client = BotClass()
+
+ self._bot_task = asyncio.create_task(self._run_bot())
+ logger.info("QQ bot started (C2C private message)")
+
+ async def _run_bot(self) -> None:
+ """Run the bot connection."""
+ try:
+ await self._client.start(appid=self.config.app_id, secret=self.config.secret)
+ except Exception as e:
+ logger.error(f"QQ auth failed, check AppID/Secret at q.qq.com: {e}")
+ self._running = False
+
+ async def stop(self) -> None:
+ """Stop the QQ bot."""
+ self._running = False
+ if self._bot_task:
+ self._bot_task.cancel()
+ try:
+ await self._bot_task
+ except asyncio.CancelledError:
+ pass
+ logger.info("QQ bot stopped")
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a message through QQ."""
+ if not self._client:
+ logger.warning("QQ client not initialized")
+ return
+ try:
+ await self._client.api.post_c2c_message(
+ openid=msg.chat_id,
+ msg_type=0,
+ content=msg.content,
+ )
+ except Exception as e:
+ logger.error(f"Error sending QQ message: {e}")
+
+ async def _on_message(self, data: "C2CMessage") -> None:
+ """Handle incoming message from QQ."""
+ try:
+ # Dedup by message ID
+ if data.id in self._processed_ids:
+ return
+ self._processed_ids.append(data.id)
+
+ author = data.author
+ user_id = str(getattr(author, 'id', None) or getattr(author, 'user_openid', 'unknown'))
+ content = (data.content or "").strip()
+ if not content:
+ return
+
+ await self._handle_message(
+ sender_id=user_id,
+ chat_id=user_id,
+ content=content,
+ metadata={"message_id": data.id},
+ )
+ except Exception as e:
+ logger.error(f"Error handling QQ message: {e}")
diff --git a/nanobot/channels/slack.py b/nanobot/channels/slack.py
new file mode 100644
index 0000000..be95dd2
--- /dev/null
+++ b/nanobot/channels/slack.py
@@ -0,0 +1,205 @@
+"""Slack channel implementation using Socket Mode."""
+
+import asyncio
+import re
+from typing import Any
+
+from loguru import logger
+from slack_sdk.socket_mode.websockets import SocketModeClient
+from slack_sdk.socket_mode.request import SocketModeRequest
+from slack_sdk.socket_mode.response import SocketModeResponse
+from slack_sdk.web.async_client import AsyncWebClient
+
+from nanobot.bus.events import OutboundMessage
+from nanobot.bus.queue import MessageBus
+from nanobot.channels.base import BaseChannel
+from nanobot.config.schema import SlackConfig
+
+
+class SlackChannel(BaseChannel):
+ """Slack channel using Socket Mode."""
+
+ name = "slack"
+
+ def __init__(self, config: SlackConfig, bus: MessageBus):
+ super().__init__(config, bus)
+ self.config: SlackConfig = config
+ self._web_client: AsyncWebClient | None = None
+ self._socket_client: SocketModeClient | None = None
+ self._bot_user_id: str | None = None
+
+ async def start(self) -> None:
+ """Start the Slack Socket Mode client."""
+ if not self.config.bot_token or not self.config.app_token:
+ logger.error("Slack bot/app token not configured")
+ return
+ if self.config.mode != "socket":
+ logger.error(f"Unsupported Slack mode: {self.config.mode}")
+ return
+
+ self._running = True
+
+ self._web_client = AsyncWebClient(token=self.config.bot_token)
+ self._socket_client = SocketModeClient(
+ app_token=self.config.app_token,
+ web_client=self._web_client,
+ )
+
+ self._socket_client.socket_mode_request_listeners.append(self._on_socket_request)
+
+ # Resolve bot user ID for mention handling
+ try:
+ auth = await self._web_client.auth_test()
+ self._bot_user_id = auth.get("user_id")
+ logger.info(f"Slack bot connected as {self._bot_user_id}")
+ except Exception as e:
+ logger.warning(f"Slack auth_test failed: {e}")
+
+ logger.info("Starting Slack Socket Mode client...")
+ await self._socket_client.connect()
+
+ while self._running:
+ await asyncio.sleep(1)
+
+ async def stop(self) -> None:
+ """Stop the Slack client."""
+ self._running = False
+ if self._socket_client:
+ try:
+ await self._socket_client.close()
+ except Exception as e:
+ logger.warning(f"Slack socket close failed: {e}")
+ self._socket_client = None
+
+ async def send(self, msg: OutboundMessage) -> None:
+ """Send a message through Slack."""
+ if not self._web_client:
+ logger.warning("Slack client not running")
+ return
+ try:
+ slack_meta = msg.metadata.get("slack", {}) if msg.metadata else {}
+ thread_ts = slack_meta.get("thread_ts")
+ channel_type = slack_meta.get("channel_type")
+ # Only reply in thread for channel/group messages; DMs don't use threads
+ use_thread = thread_ts and channel_type != "im"
+ await self._web_client.chat_postMessage(
+ channel=msg.chat_id,
+ text=msg.content or "",
+ thread_ts=thread_ts if use_thread else None,
+ )
+ except Exception as e:
+ logger.error(f"Error sending Slack message: {e}")
+
+ async def _on_socket_request(
+ self,
+ client: SocketModeClient,
+ req: SocketModeRequest,
+ ) -> None:
+ """Handle incoming Socket Mode requests."""
+ if req.type != "events_api":
+ return
+
+ # Acknowledge right away
+ await client.send_socket_mode_response(
+ SocketModeResponse(envelope_id=req.envelope_id)
+ )
+
+ payload = req.payload or {}
+ event = payload.get("event") or {}
+ event_type = event.get("type")
+
+ # Handle app mentions or plain messages
+ if event_type not in ("message", "app_mention"):
+ return
+
+ sender_id = event.get("user")
+ chat_id = event.get("channel")
+
+ # Ignore bot/system messages (any subtype = not a normal user message)
+ if event.get("subtype"):
+ return
+ if self._bot_user_id and sender_id == self._bot_user_id:
+ return
+
+ # Avoid double-processing: Slack sends both `message` and `app_mention`
+ # for mentions in channels. Prefer `app_mention`.
+ text = event.get("text") or ""
+ if event_type == "message" and self._bot_user_id and f"<@{self._bot_user_id}>" in text:
+ return
+
+ # Debug: log basic event shape
+ logger.debug(
+ "Slack event: type={} subtype={} user={} channel={} channel_type={} text={}",
+ event_type,
+ event.get("subtype"),
+ sender_id,
+ chat_id,
+ event.get("channel_type"),
+ text[:80],
+ )
+ if not sender_id or not chat_id:
+ return
+
+ channel_type = event.get("channel_type") or ""
+
+ if not self._is_allowed(sender_id, chat_id, channel_type):
+ return
+
+ if channel_type != "im" and not self._should_respond_in_channel(event_type, text, chat_id):
+ return
+
+ text = self._strip_bot_mention(text)
+
+ thread_ts = event.get("thread_ts") or event.get("ts")
+ # Add :eyes: reaction to the triggering message (best-effort)
+ try:
+ if self._web_client and event.get("ts"):
+ await self._web_client.reactions_add(
+ channel=chat_id,
+ name="eyes",
+ timestamp=event.get("ts"),
+ )
+ except Exception as e:
+ logger.debug(f"Slack reactions_add failed: {e}")
+
+ await self._handle_message(
+ sender_id=sender_id,
+ chat_id=chat_id,
+ content=text,
+ metadata={
+ "slack": {
+ "event": event,
+ "thread_ts": thread_ts,
+ "channel_type": channel_type,
+ }
+ },
+ )
+
+ def _is_allowed(self, sender_id: str, chat_id: str, channel_type: str) -> bool:
+ if channel_type == "im":
+ if not self.config.dm.enabled:
+ return False
+ if self.config.dm.policy == "allowlist":
+ return sender_id in self.config.dm.allow_from
+ return True
+
+ # Group / channel messages
+ if self.config.group_policy == "allowlist":
+ return chat_id in self.config.group_allow_from
+ return True
+
+ def _should_respond_in_channel(self, event_type: str, text: str, chat_id: str) -> bool:
+ if self.config.group_policy == "open":
+ return True
+ if self.config.group_policy == "mention":
+ if event_type == "app_mention":
+ return True
+ return self._bot_user_id is not None and f"<@{self._bot_user_id}>" in text
+ if self.config.group_policy == "allowlist":
+ return chat_id in self.config.group_allow_from
+ return False
+
+ def _strip_bot_mention(self, text: str) -> str:
+ if not text or not self._bot_user_id:
+ return text
+ return re.sub(rf"<@{re.escape(self._bot_user_id)}>\s*", "", text).strip()
diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py
index d4d56bc..bcadba9 100644
--- a/nanobot/cli/commands.py
+++ b/nanobot/cli/commands.py
@@ -589,6 +589,15 @@ def channels_status():
tg_config
)
+ # Slack
+ slack = config.channels.slack
+ slack_config = "socket" if slack.app_token and slack.bot_token else "[dim]not configured[/dim]"
+ table.add_row(
+ "Slack",
+ "โ" if slack.enabled else "โ",
+ slack_config
+ )
+
console.print(table)
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 26abcd7..c9bdb02 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -113,6 +113,34 @@ class MochatConfig(BaseModel):
reply_delay_ms: int = 120000
+class SlackDMConfig(BaseModel):
+ """Slack DM policy configuration."""
+ enabled: bool = True
+ policy: str = "open" # "open" or "allowlist"
+ allow_from: list[str] = Field(default_factory=list) # Allowed Slack user IDs
+
+
+class SlackConfig(BaseModel):
+ """Slack channel configuration."""
+ enabled: bool = False
+ mode: str = "socket" # "socket" supported
+ webhook_path: str = "/slack/events"
+ bot_token: str = "" # xoxb-...
+ app_token: str = "" # xapp-...
+ user_token_read_only: bool = True
+ group_policy: str = "mention" # "mention", "open", "allowlist"
+ group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
+ dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
+
+
+class QQConfig(BaseModel):
+ """QQ channel configuration using botpy SDK."""
+ enabled: bool = False
+ app_id: str = "" # ๆบๅจไบบ ID (AppID) 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)
+
+
class ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
@@ -122,6 +150,8 @@ class ChannelsConfig(BaseModel):
mochat: MochatConfig = Field(default_factory=MochatConfig)
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
email: EmailConfig = Field(default_factory=EmailConfig)
+ slack: SlackConfig = Field(default_factory=SlackConfig)
+ qq: QQConfig = Field(default_factory=QQConfig)
class AgentDefaults(BaseModel):
diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py
index 9d76c2a..dd50ed9 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -132,6 +132,10 @@ class LiteLLMProvider(LLMProvider):
# Apply model-specific overrides (e.g. kimi-k2.5 temperature)
self._apply_model_overrides(model, kwargs)
+ # Pass api_key directly โ more reliable than env vars alone
+ if self.api_key:
+ kwargs["api_key"] = self.api_key
+
# Pass api_base for custom endpoints
if self.api_base:
kwargs["api_base"] = self.api_base
diff --git a/pyproject.toml b/pyproject.toml
index 274f971..4c10d49 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "nanobot-ai"
-version = "0.1.3.post5"
+version = "0.1.3.post6"
description = "A lightweight personal AI assistant framework"
requires-python = ">=3.11"
license = {text = "MIT"}
@@ -34,6 +34,8 @@ dependencies = [
"socksio>=1.0.0",
"python-socketio>=5.11.0",
"msgpack>=1.0.8",
+ "slack-sdk>=3.26.0",
+ "qq-botpy>=1.0.0",
]
[project.optional-dependencies]
diff --git a/tests/test_mochat_channel.py b/tests/test_mochat_channel.py
deleted file mode 100644
index 4d73840..0000000
--- a/tests/test_mochat_channel.py
+++ /dev/null
@@ -1,115 +0,0 @@
-import pytest
-
-from nanobot.bus.queue import MessageBus
-from nanobot.channels.mochat import (
- MochatBufferedEntry,
- MochatChannel,
- build_buffered_body,
- resolve_mochat_target,
- resolve_require_mention,
- resolve_was_mentioned,
-)
-from nanobot.config.schema import MochatConfig, MochatGroupRule, MochatMentionConfig
-
-
-def test_resolve_mochat_target_prefixes() -> None:
- t = resolve_mochat_target("panel:abc")
- assert t.id == "abc"
- assert t.is_panel is True
-
- t = resolve_mochat_target("session_123")
- assert t.id == "session_123"
- assert t.is_panel is False
-
- t = resolve_mochat_target("mochat:session_456")
- assert t.id == "session_456"
- assert t.is_panel is False
-
-
-def test_resolve_was_mentioned_from_meta_and_text() -> None:
- payload = {
- "content": "hello",
- "meta": {
- "mentionIds": ["bot-1"],
- },
- }
- assert resolve_was_mentioned(payload, "bot-1") is True
-
- payload = {"content": "ping <@bot-2>", "meta": {}}
- assert resolve_was_mentioned(payload, "bot-2") is True
-
-
-def test_resolve_require_mention_priority() -> None:
- cfg = MochatConfig(
- groups={
- "*": MochatGroupRule(require_mention=False),
- "group-a": MochatGroupRule(require_mention=True),
- },
- mention=MochatMentionConfig(require_in_groups=False),
- )
-
- assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-a") is True
- assert resolve_require_mention(cfg, session_id="panel-x", group_id="group-b") is False
-
-
-@pytest.mark.asyncio
-async def test_delay_buffer_flushes_on_mention() -> None:
- bus = MessageBus()
- cfg = MochatConfig(
- enabled=True,
- claw_token="token",
- agent_user_id="bot",
- reply_delay_mode="non-mention",
- reply_delay_ms=60_000,
- )
- channel = MochatChannel(cfg, bus)
-
- first = {
- "type": "message.add",
- "timestamp": "2026-02-07T10:00:00Z",
- "payload": {
- "messageId": "m1",
- "author": "user1",
- "content": "first",
- "groupId": "group-1",
- "meta": {},
- },
- }
- second = {
- "type": "message.add",
- "timestamp": "2026-02-07T10:00:01Z",
- "payload": {
- "messageId": "m2",
- "author": "user2",
- "content": "hello <@bot>",
- "groupId": "group-1",
- "meta": {},
- },
- }
-
- await channel._process_inbound_event(target_id="panel-1", event=first, target_kind="panel")
- assert bus.inbound_size == 0
-
- await channel._process_inbound_event(target_id="panel-1", event=second, target_kind="panel")
- assert bus.inbound_size == 1
-
- msg = await bus.consume_inbound()
- assert msg.channel == "mochat"
- assert msg.chat_id == "panel-1"
- assert "user1: first" in msg.content
- assert "user2: hello <@bot>" in msg.content
- assert msg.metadata.get("buffered_count") == 2
-
- await channel._cancel_delay_timers()
-
-
-def test_build_buffered_body_group_labels() -> None:
- body = build_buffered_body(
- entries=[
- MochatBufferedEntry(raw_body="a", author="u1", sender_name="Alice"),
- MochatBufferedEntry(raw_body="b", author="u2", sender_username="bot"),
- ],
- is_group=True,
- )
- assert "Alice: a" in body
- assert "bot: b" in body