resolve merge conflicts: keep all channels and add QQ
This commit is contained in:
commit
2c45657b14
48
README.md
48
README.md
@ -166,7 +166,7 @@ nanobot agent -m "Hello from my local LLM!"
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Email, or QQ — anytime, anywhere.
|
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, Email, or QQ — anytime, anywhere.
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
@ -175,6 +175,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Emai
|
|||||||
| **WhatsApp** | Medium (scan QR) |
|
| **WhatsApp** | Medium (scan QR) |
|
||||||
| **Feishu** | Medium (app credentials) |
|
| **Feishu** | Medium (app credentials) |
|
||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
|
| **Slack** | Medium (bot + app tokens) |
|
||||||
| **Email** | Medium (IMAP/SMTP credentials) |
|
| **Email** | Medium (IMAP/SMTP credentials) |
|
||||||
| **QQ** | Easy (app credentials) |
|
| **QQ** | Easy (app credentials) |
|
||||||
|
|
||||||
@ -200,7 +201,9 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Emai
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 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**
|
**3. Run**
|
||||||
|
|
||||||
@ -414,6 +417,44 @@ nanobot gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>Slack</b></summary>
|
||||||
|
|
||||||
|
Uses **Socket Mode** — no public URL required.
|
||||||
|
|
||||||
|
**1. Create a Slack app**
|
||||||
|
- Go to [Slack API](https://api.slack.com/apps) → Create New App
|
||||||
|
- **OAuth & Permissions**: Add bot scopes: `chat:write`, `reactions:write`, `app_mentions:read`
|
||||||
|
- Install to your workspace and copy the **Bot Token** (`xoxb-...`)
|
||||||
|
- **Socket Mode**: Enable it and generate an **App-Level Token** (`xapp-...`) with `connections:write` scope
|
||||||
|
- **Event Subscriptions**: Subscribe to `message.im`, `message.channels`, `app_mention`
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"slack": {
|
||||||
|
"enabled": true,
|
||||||
|
"botToken": "xoxb-...",
|
||||||
|
"appToken": "xapp-...",
|
||||||
|
"groupPolicy": "mention"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `groupPolicy`: `"mention"` (respond only when @mentioned), `"open"` (respond to all messages), or `"allowlist"` (restrict to specific channels).
|
||||||
|
> DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Email</b></summary>
|
<summary><b>Email</b></summary>
|
||||||
|
|
||||||
@ -534,7 +575,6 @@ That's it! Environment variables, model prefixing, config matching, and `nanobot
|
|||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
> [!TIP]
|
|
||||||
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
> For production deployments, set `"restrictToWorkspace": true` in your config to sandbox the agent.
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
@ -633,7 +673,7 @@ PRs welcome! The codebase is intentionally small and readable. 🤗
|
|||||||
- [ ] **Multi-modal** — See and hear (images, voice, video)
|
- [ ] **Multi-modal** — See and hear (images, voice, video)
|
||||||
- [ ] **Long-term memory** — Never forget important context
|
- [ ] **Long-term memory** — Never forget important context
|
||||||
- [ ] **Better reasoning** — Multi-step planning and reflection
|
- [ ] **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
|
- [ ] **Self-improvement** — Learn from feedback and mistakes
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
|||||||
@ -245,7 +245,8 @@ class AgentLoop:
|
|||||||
return OutboundMessage(
|
return OutboundMessage(
|
||||||
channel=msg.channel,
|
channel=msg.channel,
|
||||||
chat_id=msg.chat_id,
|
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:
|
async def _process_system_message(self, msg: InboundMessage) -> OutboundMessage | None:
|
||||||
|
|||||||
@ -107,6 +107,17 @@ class ChannelManager:
|
|||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Email channel not available: {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
|
# QQ channel
|
||||||
if self.config.channels.qq.enabled:
|
if self.config.channels.qq.enabled:
|
||||||
try:
|
try:
|
||||||
|
|||||||
205
nanobot/channels/slack.py
Normal file
205
nanobot/channels/slack.py
Normal file
@ -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()
|
||||||
@ -571,6 +571,15 @@ def channels_status():
|
|||||||
tg_config
|
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)
|
console.print(table)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -77,6 +77,26 @@ class EmailConfig(BaseModel):
|
|||||||
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
|
allow_from: list[str] = Field(default_factory=list) # Allowed sender email addresses
|
||||||
|
|
||||||
|
|
||||||
|
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 = "open" # "open", "mention", "allowlist"
|
||||||
|
group_allow_from: list[str] = Field(default_factory=list) # Allowed channel IDs if allowlist
|
||||||
|
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
||||||
|
|
||||||
|
|
||||||
class QQConfig(BaseModel):
|
class QQConfig(BaseModel):
|
||||||
"""QQ channel configuration using botpy SDK."""
|
"""QQ channel configuration using botpy SDK."""
|
||||||
enabled: bool = False
|
enabled: bool = False
|
||||||
@ -93,6 +113,7 @@ class ChannelsConfig(BaseModel):
|
|||||||
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
|
||||||
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
|
dingtalk: DingTalkConfig = Field(default_factory=DingTalkConfig)
|
||||||
email: EmailConfig = Field(default_factory=EmailConfig)
|
email: EmailConfig = Field(default_factory=EmailConfig)
|
||||||
|
slack: SlackConfig = Field(default_factory=SlackConfig)
|
||||||
qq: QQConfig = Field(default_factory=QQConfig)
|
qq: QQConfig = Field(default_factory=QQConfig)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -32,6 +32,7 @@ dependencies = [
|
|||||||
"python-telegram-bot[socks]>=21.0",
|
"python-telegram-bot[socks]>=21.0",
|
||||||
"lark-oapi>=1.0.0",
|
"lark-oapi>=1.0.0",
|
||||||
"socksio>=1.0.0",
|
"socksio>=1.0.0",
|
||||||
|
"slack-sdk>=3.26.0",
|
||||||
"qq-botpy>=1.0.0",
|
"qq-botpy>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user