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",