diff --git a/README.md b/README.md
index e54bb8f..467f8e3 100644
--- a/README.md
+++ b/README.md
@@ -166,12 +166,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) |
Telegram (Recommended)
@@ -242,6 +243,55 @@ nanobot gateway
+
+Feishu (ι£δΉ¦)
+
+Uses **WebSocket** long connection β no public IP required.
+
+```bash
+pip install nanobot-ai[feishu]
+```
+
+**1. Create a Feishu bot**
+- Visit [Feishu Open Platform](https://open.feishu.cn/app)
+- Create a new app β Enable **Bot** capability
+- **Permissions**: Add `im:message` (send messages)
+- **Events**: Add `im.message.receive_v1` (receive messages)
+ - Select **Long Connection** mode (requires running nanobot first to establish connection)
+- Get **App ID** and **App Secret** from "Credentials & Basic Info"
+- Publish the app
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "appId": "cli_xxx",
+ "appSecret": "xxx",
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
+ }
+ }
+}
+```
+
+> `encryptKey` and `verificationToken` are optional for Long Connection mode.
+> `allowFrom`: Leave empty to allow all users, or add `["ou_xxx"]` to restrict access.
+
+**3. Run**
+
+```bash
+nanobot gateway
+```
+
+> [!TIP]
+> Feishu uses WebSocket to receive messages β no webhook or public IP needed!
+
+
+
## βοΈ Configuration
Config file: `~/.nanobot/config.json`
@@ -286,6 +336,14 @@ Config file: `~/.nanobot/config.json`
},
"whatsapp": {
"enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "appId": "cli_xxx",
+ "appSecret": "xxx",
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
}
},
"tools": {
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
new file mode 100644
index 0000000..01b808e
--- /dev/null
+++ b/nanobot/channels/feishu.py
@@ -0,0 +1,263 @@
+"""Feishu/Lark channel implementation using lark-oapi SDK with WebSocket long connection."""
+
+import asyncio
+import json
+import threading
+from collections import OrderedDict
+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,
+ Emoji,
+ P2ImMessageReceiveV1,
+ )
+ FEISHU_AVAILABLE = True
+except ImportError:
+ FEISHU_AVAILABLE = False
+ lark = None
+ Emoji = None
+
+# Message type display mapping
+MSG_TYPE_MAP = {
+ "image": "[image]",
+ "audio": "[audio]",
+ "file": "[file]",
+ "sticker": "[sticker]",
+}
+
+
+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: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
+ 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_running_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
+ if self._ws_client:
+ try:
+ self._ws_client.stop()
+ except Exception as e:
+ logger.warning(f"Error stopping WebSocket client: {e}")
+ logger.info("Feishu bot stopped")
+
+ def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
+ """Sync helper for adding reaction (runs in thread pool)."""
+ try:
+ 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.debug(f"Added {emoji_type} reaction to message {message_id}")
+ except Exception as e:
+ logger.warning(f"Error adding reaction: {e}")
+
+ async def _add_reaction(self, message_id: str, emoji_type: str = "THUMBSUP") -> None:
+ """
+ Add a reaction emoji to a message (non-blocking).
+
+ Common emoji types: THUMBSUP, OK, EYES, DONE, OnIt, HEART
+ """
+ if not self._client or not Emoji:
+ return
+
+ loop = asyncio.get_running_loop()
+ await loop.run_in_executor(None, self._add_reaction_sync, message_id, emoji_type)
+
+ 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.
+ """
+ if self._loop and self._loop.is_running():
+ asyncio.run_coroutine_threadsafe(self._on_message(data), self._loop)
+
+ async def _on_message(self, data: "P2ImMessageReceiveV1") -> None:
+ """Handle incoming message from Feishu."""
+ try:
+ event = data.event
+ message = event.message
+ sender = event.sender
+
+ # Deduplication check
+ message_id = message.message_id
+ if message_id in self._processed_message_ids:
+ return
+ self._processed_message_ids[message_id] = None
+
+ # Trim cache: keep most recent 500 when exceeds 1000
+ while len(self._processed_message_ids) > 1000:
+ self._processed_message_ids.popitem(last=False)
+
+ # Skip bot messages
+ sender_type = sender.sender_type
+ if sender_type == "bot":
+ return
+
+ sender_id = sender.sender_id.open_id if sender.sender_id else "unknown"
+ chat_id = message.chat_id
+ chat_type = message.chat_type # "p2p" or "group"
+ msg_type = message.message_type
+
+ # Add reaction to indicate "seen"
+ await self._add_reaction(message_id, "THUMBSUP")
+
+ # Parse message content
+ if msg_type == "text":
+ try:
+ content = json.loads(message.content).get("text", "")
+ except json.JSONDecodeError:
+ content = message.content or ""
+ else:
+ content = MSG_TYPE_MAP.get(msg_type, f"[{msg_type}]")
+
+ if not content:
+ return
+
+ # Forward to message bus
+ 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,
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"Error processing Feishu message: {e}")
diff --git a/nanobot/channels/manager.py b/nanobot/channels/manager.py
index 73c3334..979d01e 100644
--- a/nanobot/channels/manager.py
+++ b/nanobot/channels/manager.py
@@ -55,6 +55,17 @@ class ChannelManager:
logger.info("WhatsApp channel enabled")
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."""
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index 4c34834..4492096 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -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):
diff --git a/pyproject.toml b/pyproject.toml
index d578a08..0c59f66 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -32,6 +32,9 @@ dependencies = [
]
[project.optional-dependencies]
+feishu = [
+ "lark-oapi>=1.0.0",
+]
dev = [
"pytest>=7.0.0",
"pytest-asyncio>=0.21.0",