Merge pull request #383: add QQ channel support
This commit is contained in:
commit
ec09ff4ce0
3
.gitignore
vendored
3
.gitignore
vendored
@ -17,4 +17,5 @@ docs/
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
poetry.lock
|
poetry.lock
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
tests/
|
tests/
|
||||||
|
botpy.log
|
||||||
|
|||||||
42
README.md
42
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, Slack, or Email — anytime, anywhere.
|
Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slack, Email, or QQ — anytime, anywhere.
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
|---------|-------|
|
|---------|-------|
|
||||||
@ -177,6 +177,7 @@ Talk to your nanobot through Telegram, Discord, WhatsApp, Feishu, DingTalk, Slac
|
|||||||
| **DingTalk** | Medium (app credentials) |
|
| **DingTalk** | Medium (app credentials) |
|
||||||
| **Slack** | Medium (bot + app tokens) |
|
| **Slack** | Medium (bot + app tokens) |
|
||||||
| **Email** | Medium (IMAP/SMTP credentials) |
|
| **Email** | Medium (IMAP/SMTP credentials) |
|
||||||
|
| **QQ** | Easy (app credentials) |
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Telegram</b> (Recommended)</summary>
|
<summary><b>Telegram</b> (Recommended)</summary>
|
||||||
@ -338,6 +339,45 @@ nanobot gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>QQ (QQ私聊)</b></summary>
|
||||||
|
|
||||||
|
Uses **botpy SDK** with WebSocket — no public IP required.
|
||||||
|
|
||||||
|
**1. Create a QQ bot**
|
||||||
|
- Visit [QQ Open Platform](https://q.qq.com)
|
||||||
|
- Create a new bot application
|
||||||
|
- Get **AppID** and **Secret** from "Developer Settings"
|
||||||
|
|
||||||
|
**2. Configure**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"qq": {
|
||||||
|
"enabled": true,
|
||||||
|
"appId": "YOUR_APP_ID",
|
||||||
|
"secret": "YOUR_APP_SECRET",
|
||||||
|
"allowFrom": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `allowFrom`: Leave empty for public access, or add user openids to restrict access.
|
||||||
|
> Example: `"allowFrom": ["user_openid_1", "user_openid_2"]`
|
||||||
|
|
||||||
|
**3. Run**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> QQ bot currently supports **private messages only**. Group chat support coming soon!
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>DingTalk (钉钉)</b></summary>
|
<summary><b>DingTalk (钉钉)</b></summary>
|
||||||
|
|
||||||
|
|||||||
@ -117,6 +117,18 @@ class ChannelManager:
|
|||||||
logger.info("Slack channel enabled")
|
logger.info("Slack channel enabled")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
logger.warning(f"Slack channel not available: {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:
|
async def _start_channel(self, name: str, channel: BaseChannel) -> None:
|
||||||
"""Start a channel and log any exceptions."""
|
"""Start a channel and log any exceptions."""
|
||||||
|
|||||||
131
nanobot/channels/qq.py
Normal file
131
nanobot/channels/qq.py
Normal file
@ -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(c2c_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}")
|
||||||
@ -97,6 +97,14 @@ class SlackConfig(BaseModel):
|
|||||||
dm: SlackDMConfig = Field(default_factory=SlackDMConfig)
|
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):
|
class ChannelsConfig(BaseModel):
|
||||||
"""Configuration for chat channels."""
|
"""Configuration for chat channels."""
|
||||||
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
|
||||||
@ -106,6 +114,7 @@ class ChannelsConfig(BaseModel):
|
|||||||
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)
|
slack: SlackConfig = Field(default_factory=SlackConfig)
|
||||||
|
qq: QQConfig = Field(default_factory=QQConfig)
|
||||||
|
|
||||||
|
|
||||||
class AgentDefaults(BaseModel):
|
class AgentDefaults(BaseModel):
|
||||||
|
|||||||
@ -33,6 +33,7 @@ dependencies = [
|
|||||||
"lark-oapi>=1.0.0",
|
"lark-oapi>=1.0.0",
|
||||||
"socksio>=1.0.0",
|
"socksio>=1.0.0",
|
||||||
"slack-sdk>=3.26.0",
|
"slack-sdk>=3.26.0",
|
||||||
|
"qq-botpy>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user