feishu support

This commit is contained in:
tao.jun 2026-02-04 14:07:45 +08:00
parent 9a0f8fcc73
commit 50fa024ab4
5 changed files with 367 additions and 1 deletions

View File

@ -162,12 +162,13 @@ nanobot agent -m "Hello from my local LLM!"
## 💬 Chat Apps
Talk to your nanobot through Telegram or WhatsApp — anytime, anywhere.
Talk to your nanobot through Telegram, WhatsApp, or Feishu — anytime, anywhere.
| Channel | Setup |
|---------|-------|
| **Telegram** | Easy (just a token) |
| **WhatsApp** | Medium (scan QR) |
| **Feishu** | Medium (app credentials) |
<details>
<summary><b>Telegram</b> (Recommended)</summary>
@ -238,6 +239,56 @@ nanobot gateway
</details>
<details>
<summary><b>Feishu (飞书)</b></summary>
Uses **WebSocket** long connection — no public IP required.
Requires **lark-oapi** SDK:
```bash
pip install lark-oapi
```
**1. Create a Feishu bot**
- Visit [Feishu Open Platform](https://open.feishu.cn/app)
- Create a new app (Custom App)
- Enable bot capability
- Add event subscription: `im.message.receive_v1`
- Get credentials:
- **App ID** and **App Secret** from "Credentials & Basic Info"
- **Verification Token** and **Encrypt Key** from "Event Subscriptions"
**2. Configure**
```json
{
"channels": {
"feishu": {
"enabled": true,
"appId": "cli_xxx",
"appSecret": "xxx",
"verificationToken": "xxx",
"encryptKey": "xxx",
"allowFrom": ["ou_xxx"]
}
}
}
```
> Get your Open ID by sending a message to the bot, or from Feishu admin console.
**3. Run**
```bash
nanobot gateway
```
> [!TIP]
> Feishu uses WebSocket to receive messages — no webhook or public IP needed!
</details>
## ⚙️ Configuration
Config file: `~/.nanobot/config.json`
@ -282,6 +333,14 @@ Config file: `~/.nanobot/config.json`
},
"whatsapp": {
"enabled": false
},
"feishu": {
"enabled": false,
"appId": "cli_xxx",
"appSecret": "xxx",
"verificationToken": "xxx",
"encryptKey": "xxx",
"allowFrom": ["ou_xxx"]
}
},
"tools": {

281
nanobot/channels/feishu.py Normal file
View File

@ -0,0 +1,281 @@
"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
import asyncio
import json
import threading
from typing import Any
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 FeishuConfig
try:
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
CreateMessageReactionRequest,
CreateMessageReactionRequestBody,
P2ImMessageReceiveV1,
)
FEISHU_AVAILABLE = True
except ImportError:
FEISHU_AVAILABLE = False
lark = None
class FeishuChannel(BaseChannel):
"""
Feishu/Lark channel using WebSocket long connection.
Uses WebSocket to receive events - no public IP or webhook required.
Requires:
- App ID and App Secret from Feishu Open Platform
- Bot capability enabled
- Event subscription enabled (im.message.receive_v1)
"""
name = "feishu"
def __init__(self, config: FeishuConfig, bus: MessageBus):
super().__init__(config, bus)
self.config: FeishuConfig = config
self._client: Any = None
self._ws_client: Any = None
self._ws_thread: threading.Thread | None = None
self._processed_message_ids: set[str] = set() # Dedup message IDs
self._loop: asyncio.AbstractEventLoop | None = None
async def start(self) -> None:
"""Start the Feishu bot with WebSocket long connection."""
if not FEISHU_AVAILABLE:
logger.error("Feishu SDK not installed. Run: pip install lark-oapi")
return
if not self.config.app_id or not self.config.app_secret:
logger.error("Feishu app_id and app_secret not configured")
return
self._running = True
self._loop = asyncio.get_event_loop()
# Create Lark client for sending messages
self._client = lark.Client.builder() \
.app_id(self.config.app_id) \
.app_secret(self.config.app_secret) \
.log_level(lark.LogLevel.INFO) \
.build()
# Create event handler (only register message receive, ignore other events)
event_handler = lark.EventDispatcherHandler.builder(
self.config.encrypt_key or "",
self.config.verification_token or "",
).register_p2_im_message_receive_v1(
self._on_message_sync
).build()
# Create WebSocket client for long connection
self._ws_client = lark.ws.Client(
self.config.app_id,
self.config.app_secret,
event_handler=event_handler,
log_level=lark.LogLevel.INFO
)
# Start WebSocket client in a separate thread
def run_ws():
try:
self._ws_client.start()
except Exception as e:
logger.error(f"Feishu WebSocket error: {e}")
self._ws_thread = threading.Thread(target=run_ws, daemon=True)
self._ws_thread.start()
logger.info("Feishu bot started with WebSocket long connection")
logger.info("No public IP required - using WebSocket to receive events")
# Keep running until stopped
while self._running:
await asyncio.sleep(1)
async def stop(self) -> None:
"""Stop the Feishu bot."""
self._running = False
logger.info("Feishu bot stopped")
def _add_reaction(self, message_id: str, emoji_type: str = "SMILE") -> None:
"""
Add a reaction emoji to a message.
Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
"""
if not self._client:
logger.warning("Cannot add reaction: client not initialized")
return
try:
from lark_oapi.api.im.v1 import Emoji
request = CreateMessageReactionRequest.builder() \
.message_id(message_id) \
.request_body(
CreateMessageReactionRequestBody.builder()
.reaction_type(Emoji.builder().emoji_type(emoji_type).build())
.build()
).build()
response = self._client.im.v1.message_reaction.create(request)
if not response.success():
logger.warning(f"Failed to add reaction: code={response.code}, msg={response.msg}")
else:
logger.info(f"Added {emoji_type} reaction to message {message_id}")
except Exception as e:
logger.warning(f"Error adding reaction: {e}")
async def send(self, msg: OutboundMessage) -> None:
"""Send a message through Feishu."""
if not self._client:
logger.warning("Feishu client not initialized")
return
try:
# Determine receive_id_type based on chat_id format
# open_id starts with "ou_", chat_id starts with "oc_"
if msg.chat_id.startswith("oc_"):
receive_id_type = "chat_id"
else:
receive_id_type = "open_id"
# Build text message content
content = json.dumps({"text": msg.content})
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(msg.chat_id)
.msg_type("text")
.content(content)
.build()
).build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
f"Failed to send Feishu message: code={response.code}, "
f"msg={response.msg}, log_id={response.get_log_id()}"
)
else:
logger.debug(f"Feishu message sent to {msg.chat_id}")
except Exception as e:
logger.error(f"Error sending Feishu message: {e}")
def _on_message_sync(self, data: "P2ImMessageReceiveV1") -> None:
"""
Sync handler for incoming messages (called from WebSocket thread).
Schedules async handling in the main event loop.
"""
try:
if self._loop and self._loop.is_running():
# Schedule the async handler in the main event loop
asyncio.run_coroutine_threadsafe(
self._on_message(data),
self._loop
)
else:
# Fallback: run in new event loop
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._on_message(data))
finally:
loop.close()
except Exception as e:
logger.error(f"Error handling Feishu message: {e}")
async def _on_message(self, data: "P2ImMessageReceiveV1") -> None:
"""Handle incoming message from Feishu."""
try:
event = data.event
message = event.message
sender = event.sender
# Get message ID for deduplication
message_id = message.message_id
if message_id in self._processed_message_ids:
logger.debug(f"Skipping duplicate message: {message_id}")
return
self._processed_message_ids.add(message_id)
# Limit dedup cache size
if len(self._processed_message_ids) > 1000:
self._processed_message_ids = set(list(self._processed_message_ids)[-500:])
# Extract sender info
sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
sender_type = sender.sender_type # "user" or "bot"
# Skip bot messages
if sender_type == "bot":
return
# Add reaction to user's message to indicate "seen" (👍 THUMBSUP)
self._add_reaction(message_id, "THUMBSUP")
# Get chat_id for replies
chat_id = message.chat_id
chat_type = message.chat_type # "p2p" or "group"
# Parse message content
content = ""
msg_type = message.message_type
if msg_type == "text":
# Text message: {"text": "hello"}
try:
content_obj = json.loads(message.content)
content = content_obj.get("text", "")
except json.JSONDecodeError:
content = message.content or ""
elif msg_type == "image":
content = "[image]"
elif msg_type == "audio":
content = "[audio]"
elif msg_type == "file":
content = "[file]"
elif msg_type == "sticker":
content = "[sticker]"
else:
content = f"[{msg_type}]"
if not content:
return
logger.debug(f"Feishu message from {sender_id} in {chat_id}: {content[:50]}...")
# Forward to message bus
# Use chat_id for group chats, sender's open_id for p2p
reply_to = chat_id if chat_type == "group" else sender_id
await self._handle_message(
sender_id=sender_id,
chat_id=reply_to,
content=content,
metadata={
"message_id": message_id,
"chat_type": chat_type,
"msg_type": msg_type,
"sender_type": sender_type,
}
)
except Exception as e:
logger.error(f"Error processing Feishu message: {e}")

View File

@ -56,6 +56,17 @@ class ChannelManager:
except ImportError as e:
logger.warning(f"WhatsApp channel not available: {e}")
# Feishu channel
if self.config.channels.feishu.enabled:
try:
from nanobot.channels.feishu import FeishuChannel
self.channels["feishu"] = FeishuChannel(
self.config.channels.feishu, self.bus
)
logger.info("Feishu channel enabled")
except ImportError as e:
logger.warning(f"Feishu channel not available: {e}")
async def start_all(self) -> None:
"""Start WhatsApp channel and the outbound dispatcher."""
if not self.channels:

View File

@ -17,12 +17,24 @@ class TelegramConfig(BaseModel):
enabled: bool = False
token: str = "" # Bot token from @BotFather
allow_from: list[str] = Field(default_factory=list) # Allowed user IDs or usernames
proxy: str | None = None # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
class FeishuConfig(BaseModel):
"""Feishu/Lark channel configuration using WebSocket long connection."""
enabled: bool = False
app_id: str = "" # App ID from Feishu Open Platform
app_secret: str = "" # App Secret from Feishu Open Platform
encrypt_key: str = "" # Encrypt Key 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
class ChannelsConfig(BaseModel):
"""Configuration for chat channels."""
whatsapp: WhatsAppConfig = Field(default_factory=WhatsAppConfig)
telegram: TelegramConfig = Field(default_factory=TelegramConfig)
feishu: FeishuConfig = Field(default_factory=FeishuConfig)
class AgentDefaults(BaseModel):

View File

@ -32,6 +32,9 @@ dependencies = [
]
[project.optional-dependencies]
feishu = [
"lark-oapi>=1.0.0",
]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",