You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

616 lines
20 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# -*- coding: utf-8 -*-
"""
===================================
飞书 Stream 模式适配器
===================================
使用飞书官方 lark-oapi SDK 的 WebSocket 长连接模式接入机器人,
无需公网 IP 和 Webhook 配置。
优势:
- 不需要公网 IP 或域名
- 不需要配置 Webhook URL
- 通过 WebSocket 长连接接收消息
- 更简单的接入方式
- 内置自动重连和心跳保活
依赖:
pip install lark-oapi
飞书长连接文档:
https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/server-side-sdk/python--sdk/handle-events
"""
import json
import logging
import threading
from datetime import datetime
from typing import Optional, Callable
logger = logging.getLogger(__name__)
# 尝试导入飞书 SDK
try:
import lark_oapi as lark
from lark_oapi import ws
from lark_oapi.api.im.v1 import (
P2ImMessageReceiveV1,
ReplyMessageRequest,
ReplyMessageRequestBody,
CreateMessageRequest,
CreateMessageRequestBody,
)
FEISHU_SDK_AVAILABLE = True
except ImportError:
FEISHU_SDK_AVAILABLE = False
logger.warning("[Feishu Stream] lark-oapi SDK 未安装Stream 模式不可用")
logger.warning("[Feishu Stream] 请运行: pip install lark-oapi")
from bot.models import BotMessage, BotResponse, ChatType
from src.formatters import format_feishu_markdown, chunk_feishu_content
from src.config import get_config
class FeishuReplyClient:
"""
飞书消息回复客户端
使用飞书 API 发送回复消息。
"""
def __init__(self, app_id: str, app_secret: str):
"""
Args:
app_id: 飞书应用 ID
app_secret: 飞书应用密钥
"""
if not FEISHU_SDK_AVAILABLE:
raise ImportError("lark-oapi SDK 未安装")
self._client = lark.Client.builder() \
.app_id(app_id) \
.app_secret(app_secret) \
.log_level(lark.LogLevel.WARNING) \
.build()
# 获取配置的最大字节数
config = get_config()
self._max_bytes = getattr(config, 'feishu_max_bytes', 20000)
def _send_interactive_card(self, content: str, message_id: Optional[str] = None,
chat_id: Optional[str] = None,
receive_id_type: str = "chat_id",
at_user: bool = False, user_id: Optional[str] = None) -> bool:
"""
发送交互卡片消息(支持 Markdown 渲染)
Args:
content: Markdown 格式的内容
message_id: 原消息 ID回复时使用
chat_id: 会话 ID主动发送时使用
receive_id_type: 接收者 ID 类型
at_user: 是否 @用户
user_id: 用户 open_idat_user=True 时需要)
Returns:
是否发送成功
"""
try:
# 如果需要 @用户,在内容前添加 @ 标记
final_content = content
if at_user and user_id:
final_content = f"<at user_id=\"{user_id}\"></at> {content}"
# 构建交互卡片 payload
card_data = {
"config": {"wide_screen_mode": True},
"elements": [
{
"tag": "div",
"text": {
"tag": "lark_md",
"content": final_content
}
}
]
}
content_json = json.dumps(card_data)
if message_id:
# 回复消息
request = ReplyMessageRequest.builder() \
.message_id(message_id) \
.request_body(
ReplyMessageRequestBody.builder()
.content(content_json)
.msg_type("interactive")
.build()
) \
.build()
response = self._client.im.v1.message.reply(request)
else:
# 主动发送消息
request = CreateMessageRequest.builder() \
.receive_id_type(receive_id_type) \
.request_body(
CreateMessageRequestBody.builder()
.receive_id(chat_id)
.content(content_json)
.msg_type("interactive")
.build()
) \
.build()
response = self._client.im.v1.message.create(request)
if not response.success():
logger.error(
f"[Feishu Stream] 发送交互卡片失败: code={response.code}, "
f"msg={response.msg}, log_id={response.get_log_id()}"
)
return False
logger.debug(f"[Feishu Stream] 发送交互卡片成功")
return True
except Exception as e:
logger.error(f"[Feishu Stream] 发送交互卡片异常: {e}")
return False
def reply_text(self, message_id: str, text: str, at_user: bool = False,
user_id: Optional[str] = None) -> bool:
"""
回复文本消息(支持交互卡片和分段发送)
Args:
message_id: 原消息 ID
text: 回复文本
at_user: 是否 @用户
user_id: 用户 open_idat_user=True 时需要)
Returns:
是否发送成功
"""
# 将文本转换为飞书 Markdown 格式
formatted_text = format_feishu_markdown(text)
# 检查是否需要分段发送
content_bytes = len(formatted_text.encode('utf-8'))
if content_bytes > self._max_bytes:
logger.info(
f"[Feishu Stream] 回复消息内容超长({content_bytes}字节),将分批发送"
)
return chunk_feishu_content(
formatted_text,
self._max_bytes,
lambda chunk: self._send_interactive_card(
chunk, message_id=message_id, at_user=at_user, user_id=user_id
)
)
# 单条消息,使用交互卡片
return self._send_interactive_card(
formatted_text, message_id=message_id, at_user=at_user, user_id=user_id
)
def send_to_chat(self, chat_id: str, text: str,
receive_id_type: str = "chat_id") -> bool:
"""
发送消息到指定会话(支持交互卡片和分段发送)
Args:
chat_id: 会话 ID
text: 消息文本
receive_id_type: 接收者 ID 类型,默认 chat_id
Returns:
是否发送成功
"""
# 将文本转换为飞书 Markdown 格式
formatted_text = format_feishu_markdown(text)
# 检查是否需要分段发送
content_bytes = len(formatted_text.encode('utf-8'))
if content_bytes > self._max_bytes:
logger.info(
f"[Feishu Stream] 发送消息内容超长({content_bytes}字节),将分批发送"
)
return chunk_feishu_content(
formatted_text,
self._max_bytes,
lambda chunk: self._send_interactive_card(
chunk, chat_id=chat_id, receive_id_type=receive_id_type
)
)
# 单条消息,使用交互卡片
return self._send_interactive_card(
formatted_text, chat_id=chat_id, receive_id_type=receive_id_type
)
class FeishuStreamHandler:
"""
飞书 Stream 模式消息处理器
将 SDK 的事件转换为统一的 BotMessage 格式,
并调用命令分发器处理。
"""
def __init__(
self,
on_message: Callable[[BotMessage], BotResponse],
reply_client: FeishuReplyClient
):
"""
Args:
on_message: 消息处理回调函数,接收 BotMessage 返回 BotResponse
reply_client: 飞书回复客户端
"""
self._on_message = on_message
self._reply_client = reply_client
self._logger = logger
@staticmethod
def _truncate_log_content(text: str, max_len: int = 200) -> str:
"""截断日志内容"""
cleaned = text.replace("\n", " ").strip()
if len(cleaned) > max_len:
return f"{cleaned[:max_len]}..."
return cleaned
def _log_incoming_message(self, message: BotMessage) -> None:
"""记录收到的消息日志"""
content = message.raw_content or message.content or ""
summary = self._truncate_log_content(content)
self._logger.info(
"[Feishu Stream] Incoming message: msg_id=%s user_id=%s "
"chat_id=%s chat_type=%s content=%s",
message.message_id,
message.user_id,
message.chat_id,
getattr(message.chat_type, "value", message.chat_type),
summary,
)
def handle_message(self, event: 'P2ImMessageReceiveV1') -> None:
"""
处理接收到的消息事件
Args:
event: 飞书消息接收事件
"""
try:
# 解析消息
bot_message = self._parse_event_message(event)
if bot_message is None:
return
self._log_incoming_message(bot_message)
# 调用消息处理回调
response = self._on_message(bot_message)
# 发送回复
if response and response.text:
self._reply_client.reply_text(
message_id=bot_message.message_id,
text=response.text,
at_user=response.at_user,
user_id=bot_message.user_id if response.at_user else None
)
except Exception as e:
self._logger.error(f"[Feishu Stream] 处理消息失败: {e}")
self._logger.exception(e)
def _parse_event_message(self, event: 'P2ImMessageReceiveV1') -> Optional[BotMessage]:
"""
解析飞书事件消息为统一格式
Args:
event: P2ImMessageReceiveV1 事件对象
"""
try:
event_data = event.event
if event_data is None:
return None
message_data = event_data.message
sender_data = event_data.sender
if message_data is None:
return None
# 只处理文本消息
message_type = message_data.message_type or ""
if message_type != "text":
self._logger.debug(f"[Feishu Stream] 忽略非文本消息: {message_type}")
return None
# 解析消息内容
content_str = message_data.content or "{}"
try:
content_json = json.loads(content_str)
raw_content = content_json.get("text", "")
except json.JSONDecodeError:
raw_content = content_str
# 提取命令(去除 @机器人)
content = self._extract_command(raw_content, message_data.mentions)
mentioned = "@" in raw_content or bool(message_data.mentions)
# 获取发送者信息
user_id = ""
if sender_data and sender_data.sender_id:
user_id = sender_data.sender_id.open_id or sender_data.sender_id.user_id or ""
# 获取会话类型
chat_type_str = message_data.chat_type or ""
if chat_type_str == "group":
chat_type = ChatType.GROUP
elif chat_type_str == "p2p":
chat_type = ChatType.PRIVATE
else:
chat_type = ChatType.UNKNOWN
# 创建时间
create_time = message_data.create_time
try:
if create_time:
timestamp = datetime.fromtimestamp(int(create_time) / 1000)
else:
timestamp = datetime.now()
except (ValueError, TypeError):
timestamp = datetime.now()
# 构建原始数据
raw_data = {
"header": {
"event_id": event.header.event_id if event.header else "",
"event_type": event.header.event_type if event.header else "",
"create_time": event.header.create_time if event.header else "",
"token": event.header.token if event.header else "",
"app_id": event.header.app_id if event.header else "",
},
"event": {
"message_id": message_data.message_id,
"chat_id": message_data.chat_id,
"chat_type": message_data.chat_type,
"content": message_data.content,
}
}
return BotMessage(
platform="feishu",
message_id=message_data.message_id or "",
user_id=user_id,
user_name=user_id, # 飞书不直接返回用户名
chat_id=message_data.chat_id or "",
chat_type=chat_type,
content=content,
raw_content=raw_content,
mentioned=mentioned,
mentions=[m.key or "" for m in (message_data.mentions or [])],
timestamp=timestamp,
raw_data=raw_data,
)
except Exception as e:
self._logger.error(f"[Feishu Stream] 解析消息失败: {e}")
return None
def _extract_command(self, text: str, mentions: list) -> str:
"""
提取命令内容(去除 @机器人)
飞书的 @用户 格式是:@_user_1, @_user_2 等
Args:
text: 原始消息文本
mentions: @提及列表
"""
import re
# 方式1: 通过 mentions 列表移除(精确匹配)
for mention in (mentions or []):
key = getattr(mention, 'key', '') or ''
if key:
text = text.replace(key, '')
# 方式2: 正则兜底,移除飞书 @用户 格式(@_user_N
# 当 mentions 为空或未正确传递时生效
text = re.sub(r'@_user_\d+\s*', '', text)
# 清理多余空格
return ' '.join(text.split())
class FeishuStreamClient:
"""
飞书 Stream 模式客户端
封装 lark-oapi SDK 的 WebSocket 客户端,提供简单的启动接口。
使用方式:
client = FeishuStreamClient()
client.start() # 阻塞运行
# 或者在后台运行
client.start_background()
"""
def __init__(
self,
app_id: Optional[str] = None,
app_secret: Optional[str] = None
):
"""
Args:
app_id: 应用 ID不传则从配置读取
app_secret: 应用密钥(不传则从配置读取)
"""
if not FEISHU_SDK_AVAILABLE:
raise ImportError(
"lark-oapi SDK 未安装。\n"
"请运行: pip install lark-oapi"
)
from src.config import get_config
config = get_config()
self._app_id = app_id or getattr(config, 'feishu_app_id', None)
self._app_secret = app_secret or getattr(config, 'feishu_app_secret', None)
if not self._app_id or not self._app_secret:
raise ValueError(
"飞书 Stream 模式需要配置 FEISHU_APP_ID 和 FEISHU_APP_SECRET"
)
self._ws_client: Optional[ws.Client] = None
self._reply_client: Optional[FeishuReplyClient] = None
self._background_thread: Optional[threading.Thread] = None
self._running = False
def _create_message_handler(self) -> Callable[[BotMessage], BotResponse]:
"""创建消息处理函数"""
def handle_message(message: BotMessage) -> BotResponse:
from bot.dispatcher import get_dispatcher
dispatcher = get_dispatcher()
return dispatcher.dispatch(message)
return handle_message
def _create_event_handler(self) -> 'lark.EventDispatcherHandler':
"""创建事件分发处理器"""
# 创建回复客户端
self._reply_client = FeishuReplyClient(self._app_id, self._app_secret)
# 创建消息处理器
handler = FeishuStreamHandler(
self._create_message_handler(),
self._reply_client
)
# 创建并注册事件处理器
# 注意encrypt_key 和 verification_token 在长连接模式下不是必需的
# 但 SDK 要求传入(可以为空字符串)
from src.config import get_config
config = get_config()
encrypt_key = getattr(config, 'feishu_encrypt_key', '') or ''
verification_token = getattr(config, 'feishu_verification_token', '') or ''
event_handler = lark.EventDispatcherHandler.builder(
encrypt_key=encrypt_key,
verification_token=verification_token,
level=lark.LogLevel.WARNING
).register_p2_im_message_receive_v1(
handler.handle_message
).build()
return event_handler
def start(self) -> None:
"""
启动 Stream 客户端(阻塞)
此方法会阻塞当前线程,直到客户端停止。
"""
logger.info("[Feishu Stream] 正在启动...")
# 创建事件处理器
event_handler = self._create_event_handler()
# 创建 WebSocket 客户端
self._ws_client = ws.Client(
app_id=self._app_id,
app_secret=self._app_secret,
event_handler=event_handler,
log_level=lark.LogLevel.WARNING,
auto_reconnect=True
)
self._running = True
logger.info("[Feishu Stream] 客户端已启动,等待消息...")
# 启动(阻塞)
self._ws_client.start()
def start_background(self) -> None:
"""
在后台线程启动 Stream 客户端(非阻塞)
适用于与其他服务(如 WebUI同时运行的场景。
"""
if self._background_thread and self._background_thread.is_alive():
logger.warning("[Feishu Stream] 客户端已在运行")
return
self._running = True
self._background_thread = threading.Thread(
target=self._run_in_background,
daemon=True,
name="FeishuStreamClient"
)
self._background_thread.start()
logger.info("[Feishu Stream] 后台客户端已启动")
def _run_in_background(self) -> None:
"""后台运行(处理异常和重连)"""
import time
while self._running:
try:
self.start()
except Exception as e:
logger.error(f"[Feishu Stream] 运行异常: {e}")
if self._running:
logger.info("[Feishu Stream] 5 秒后重连...")
time.sleep(5)
def stop(self) -> None:
"""停止客户端"""
self._running = False
logger.info("[Feishu Stream] 客户端已停止")
@property
def is_running(self) -> bool:
"""是否正在运行"""
return self._running
# 全局客户端实例
_stream_client: Optional[FeishuStreamClient] = None
def get_feishu_stream_client() -> Optional[FeishuStreamClient]:
"""获取全局 Stream 客户端实例"""
global _stream_client
if _stream_client is None and FEISHU_SDK_AVAILABLE:
try:
_stream_client = FeishuStreamClient()
except (ImportError, ValueError) as e:
logger.warning(f"[Feishu Stream] 无法创建客户端: {e}")
return None
return _stream_client
def start_feishu_stream_background() -> bool:
"""
在后台启动飞书 Stream 客户端
Returns:
是否成功启动
"""
client = get_feishu_stream_client()
if client:
client.start_background()
return True
return False