From 50fa024ab4d44c074157c9b5278717d769445e3a Mon Sep 17 00:00:00 2001
From: "tao.jun" <61566027@163.com>
Date: Wed, 4 Feb 2026 14:07:45 +0800
Subject: [PATCH 1/3] feishu support
---
README.md | 61 +++++++-
nanobot/channels/feishu.py | 281 ++++++++++++++++++++++++++++++++++++
nanobot/channels/manager.py | 11 ++
nanobot/config/schema.py | 12 ++
pyproject.toml | 3 +
5 files changed, 367 insertions(+), 1 deletion(-)
create mode 100644 nanobot/channels/feishu.py
diff --git a/README.md b/README.md
index f4b1df2..efa6821 100644
--- a/README.md
+++ b/README.md
@@ -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) |
Telegram (Recommended)
@@ -238,6 +239,56 @@ nanobot gateway
+
+Feishu (ι£δΉ¦)
+
+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!
+
+
+
## βοΈ 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": {
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
new file mode 100644
index 0000000..4326cf0
--- /dev/null
+++ b/nanobot/channels/feishu.py
@@ -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}")
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 d081dd7..e027097 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",
From 50a4c4ca1ab9104a945a552396ee42c8d6337e7d Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 5 Feb 2026 06:01:02 +0000
Subject: [PATCH 2/3] refactor: improve feishu channel implementation
---
nanobot/channels/feishu.py | 114 ++++++++++++++++---------------------
1 file changed, 48 insertions(+), 66 deletions(-)
diff --git a/nanobot/channels/feishu.py b/nanobot/channels/feishu.py
index 4326cf0..01b808e 100644
--- a/nanobot/channels/feishu.py
+++ b/nanobot/channels/feishu.py
@@ -3,6 +3,7 @@
import asyncio
import json
import threading
+from collections import OrderedDict
from typing import Any
from loguru import logger
@@ -19,12 +20,22 @@ try:
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):
@@ -47,7 +58,7 @@ class FeishuChannel(BaseChannel):
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._processed_message_ids: OrderedDict[str, None] = OrderedDict() # Ordered dedup cache
self._loop: asyncio.AbstractEventLoop | None = None
async def start(self) -> None:
@@ -61,7 +72,7 @@ class FeishuChannel(BaseChannel):
return
self._running = True
- self._loop = asyncio.get_event_loop()
+ self._loop = asyncio.get_running_loop()
# Create Lark client for sending messages
self._client = lark.Client.builder() \
@@ -106,21 +117,16 @@ class FeishuChannel(BaseChannel):
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(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
-
+ def _add_reaction_sync(self, message_id: str, emoji_type: str) -> None:
+ """Sync helper for adding reaction (runs in thread pool)."""
try:
- from lark_oapi.api.im.v1 import Emoji
-
request = CreateMessageReactionRequest.builder() \
.message_id(message_id) \
.request_body(
@@ -134,9 +140,21 @@ class FeishuChannel(BaseChannel):
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}")
+ 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."""
@@ -183,23 +201,8 @@ class FeishuChannel(BaseChannel):
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}")
+ 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."""
@@ -208,63 +211,43 @@ class FeishuChannel(BaseChannel):
message = event.message
sender = event.sender
- # Get message ID for deduplication
+ # Deduplication check
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)
+ self._processed_message_ids[message_id] = None
- # 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"
+ # 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
- # Add reaction to user's message to indicate "seen" (π THUMBSUP)
- self._add_reaction(message_id, "THUMBSUP")
-
- # Get chat_id for replies
+ 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"
-
- # Parse message content
- content = ""
msg_type = message.message_type
+ # Add reaction to indicate "seen"
+ await self._add_reaction(message_id, "THUMBSUP")
+
+ # Parse message content
if msg_type == "text":
- # Text message: {"text": "hello"}
try:
- content_obj = json.loads(message.content)
- content = content_obj.get("text", "")
+ content = json.loads(message.content).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}]"
+ content = MSG_TYPE_MAP.get(msg_type, 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,
@@ -273,7 +256,6 @@ class FeishuChannel(BaseChannel):
"message_id": message_id,
"chat_type": chat_type,
"msg_type": msg_type,
- "sender_type": sender_type,
}
)
From f341de075de7120019ab9033322dd50a101beca7 Mon Sep 17 00:00:00 2001
From: Re-bin
Date: Thu, 5 Feb 2026 06:05:09 +0000
Subject: [PATCH 3/3] docs: simplify Feishu configuration guide
---
README.md | 31 +++++++++++++++----------------
1 file changed, 15 insertions(+), 16 deletions(-)
diff --git a/README.md b/README.md
index 5832380..467f8e3 100644
--- a/README.md
+++ b/README.md
@@ -248,20 +248,18 @@ nanobot gateway
Uses **WebSocket** long connection β no public IP required.
-Requires **lark-oapi** SDK:
-
```bash
-pip install lark-oapi
+pip install nanobot-ai[feishu]
```
**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"
+- 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**
@@ -272,15 +270,16 @@ pip install lark-oapi
"enabled": true,
"appId": "cli_xxx",
"appSecret": "xxx",
- "verificationToken": "xxx",
- "encryptKey": "xxx",
- "allowFrom": ["ou_xxx"]
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
}
}
}
```
-> Get your Open ID by sending a message to the bot, or from Feishu admin console.
+> `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**
@@ -342,9 +341,9 @@ Config file: `~/.nanobot/config.json`
"enabled": false,
"appId": "cli_xxx",
"appSecret": "xxx",
- "verificationToken": "xxx",
- "encryptKey": "xxx",
- "allowFrom": ["ou_xxx"]
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
}
},
"tools": {