fix: 飞书长连接通知
@@ -104,6 +104,12 @@ DINGTALK_APP_SECRET=xxxx
|
||||
# 启用 Stream 模式
|
||||
DINGTALK_STREAM_ENABLED=true
|
||||
|
||||
# 飞书应用机器人配置
|
||||
FEISHU_APP_ID=xxxx
|
||||
FEISHU_APP_SECRET=xxxx
|
||||
# 启用长连接模式
|
||||
FEISHU_STREAM_ENABLED=true
|
||||
|
||||
# 数据库路径
|
||||
DATABASE_PATH=./data/stock_analysis.db
|
||||
|
||||
|
||||
@@ -8,16 +8,14 @@
|
||||
|
||||
支持两种接入模式:
|
||||
1. Webhook 模式:需要公网 IP,配置回调 URL
|
||||
2. Stream 模式:无需公网 IP,通过 WebSocket 长连接(钉钉支持)
|
||||
2. Stream 模式:无需公网 IP,通过 WebSocket 长连接(钉钉、飞书支持)
|
||||
"""
|
||||
|
||||
from bot.platforms.base import BotPlatform
|
||||
from bot.platforms.feishu import FeishuPlatform
|
||||
from bot.platforms.dingtalk import DingtalkPlatform
|
||||
|
||||
# 所有可用平台(Webhook 模式)
|
||||
ALL_PLATFORMS = {
|
||||
'feishu': FeishuPlatform,
|
||||
'dingtalk': DingtalkPlatform,
|
||||
}
|
||||
|
||||
@@ -37,15 +35,39 @@ except ImportError:
|
||||
get_dingtalk_stream_client = lambda: None
|
||||
start_dingtalk_stream_background = lambda: False
|
||||
|
||||
# 飞书 Stream 模式(可选)
|
||||
try:
|
||||
from bot.platforms.feishu_stream import (
|
||||
FeishuStreamClient,
|
||||
FeishuStreamHandler,
|
||||
FeishuReplyClient,
|
||||
get_feishu_stream_client,
|
||||
start_feishu_stream_background,
|
||||
FEISHU_SDK_AVAILABLE,
|
||||
)
|
||||
except ImportError:
|
||||
FEISHU_SDK_AVAILABLE = False
|
||||
FeishuStreamClient = None
|
||||
FeishuStreamHandler = None
|
||||
FeishuReplyClient = None
|
||||
get_feishu_stream_client = lambda: None
|
||||
start_feishu_stream_background = lambda: False
|
||||
|
||||
__all__ = [
|
||||
'BotPlatform',
|
||||
'FeishuPlatform',
|
||||
'DingtalkPlatform',
|
||||
'ALL_PLATFORMS',
|
||||
# Stream 模式
|
||||
# 钉钉 Stream 模式
|
||||
'DingtalkStreamClient',
|
||||
'DingtalkStreamHandler',
|
||||
'get_dingtalk_stream_client',
|
||||
'start_dingtalk_stream_background',
|
||||
'DINGTALK_STREAM_AVAILABLE',
|
||||
# 飞书 Stream 模式
|
||||
'FeishuStreamClient',
|
||||
'FeishuStreamHandler',
|
||||
'FeishuReplyClient',
|
||||
'get_feishu_stream_client',
|
||||
'start_feishu_stream_background',
|
||||
'FEISHU_SDK_AVAILABLE',
|
||||
]
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
===================================
|
||||
飞书平台适配器
|
||||
===================================
|
||||
|
||||
处理飞书机器人的 Webhook 回调。
|
||||
|
||||
飞书机器人文档:
|
||||
https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO24yNxkjN
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from bot.platforms.base import BotPlatform
|
||||
from bot.models import BotMessage, BotResponse, WebhookResponse, ChatType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeishuPlatform(BotPlatform):
|
||||
"""
|
||||
飞书平台适配器
|
||||
|
||||
支持:
|
||||
- 事件订阅回调(机器人收到消息)
|
||||
- URL 验证(配置回调地址时的验证请求)
|
||||
- 消息签名验证
|
||||
|
||||
配置要求:
|
||||
- FEISHU_APP_ID: 应用 ID
|
||||
- FEISHU_APP_SECRET: 应用密钥
|
||||
- FEISHU_VERIFICATION_TOKEN: 事件订阅验证 Token
|
||||
- FEISHU_ENCRYPT_KEY: 加密密钥(可选)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
self._app_id = getattr(config, 'feishu_app_id', None)
|
||||
self._app_secret = getattr(config, 'feishu_app_secret', None)
|
||||
self._verification_token = getattr(config, 'feishu_verification_token', None)
|
||||
self._encrypt_key = getattr(config, 'feishu_encrypt_key', None)
|
||||
|
||||
@property
|
||||
def platform_name(self) -> str:
|
||||
return "feishu"
|
||||
|
||||
def verify_request(self, headers: Dict[str, str], body: bytes) -> bool:
|
||||
"""
|
||||
验证飞书请求签名
|
||||
|
||||
飞书使用 X-Lark-Signature 头进行签名验证。
|
||||
签名算法:sha256(timestamp + nonce + encrypt_key + body)
|
||||
"""
|
||||
if not self._verification_token:
|
||||
# 未配置验证 Token,跳过验证(开发环境)
|
||||
logger.warning("[Feishu] 未配置 verification_token,跳过签名验证")
|
||||
return True
|
||||
|
||||
# 获取签名相关头
|
||||
timestamp = headers.get('X-Lark-Request-Timestamp', '')
|
||||
nonce = headers.get('X-Lark-Request-Nonce', '')
|
||||
signature = headers.get('X-Lark-Signature', '')
|
||||
|
||||
if not signature:
|
||||
# 没有签名头,可能是旧版本或验证请求
|
||||
return True
|
||||
|
||||
# 计算签名
|
||||
if self._encrypt_key:
|
||||
sign_string = f"{timestamp}{nonce}{self._encrypt_key}{body.decode('utf-8')}"
|
||||
else:
|
||||
sign_string = f"{timestamp}{nonce}{body.decode('utf-8')}"
|
||||
|
||||
expected_signature = hashlib.sha256(sign_string.encode('utf-8')).hexdigest()
|
||||
|
||||
if signature != expected_signature:
|
||||
logger.warning(f"[Feishu] 签名验证失败: expected={expected_signature}, got={signature}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def handle_challenge(self, data: Dict[str, Any]) -> Optional[WebhookResponse]:
|
||||
"""
|
||||
处理飞书 URL 验证请求
|
||||
|
||||
配置事件订阅时,飞书会发送验证请求:
|
||||
{
|
||||
"challenge": "xxx",
|
||||
"token": "xxx",
|
||||
"type": "url_verification"
|
||||
}
|
||||
|
||||
需要返回:
|
||||
{"challenge": "xxx"}
|
||||
"""
|
||||
if data.get('type') == 'url_verification':
|
||||
challenge = data.get('challenge', '')
|
||||
token = data.get('token', '')
|
||||
|
||||
# 验证 token
|
||||
if self._verification_token and token != self._verification_token:
|
||||
logger.warning(f"[Feishu] 验证 token 不匹配")
|
||||
return WebhookResponse.error("Invalid token", 403)
|
||||
|
||||
logger.info(f"[Feishu] URL 验证成功")
|
||||
return WebhookResponse.challenge(challenge)
|
||||
|
||||
return None
|
||||
|
||||
def parse_message(self, data: Dict[str, Any]) -> Optional[BotMessage]:
|
||||
"""
|
||||
解析飞书消息
|
||||
|
||||
飞书事件格式(v2.0):
|
||||
{
|
||||
"schema": "2.0",
|
||||
"header": {
|
||||
"event_id": "xxx",
|
||||
"event_type": "im.message.receive_v1",
|
||||
"create_time": "1234567890",
|
||||
"token": "xxx",
|
||||
"app_id": "xxx"
|
||||
},
|
||||
"event": {
|
||||
"sender": {
|
||||
"sender_id": {"open_id": "xxx", "user_id": "xxx"},
|
||||
"sender_type": "user"
|
||||
},
|
||||
"message": {
|
||||
"message_id": "xxx",
|
||||
"chat_id": "xxx",
|
||||
"chat_type": "group",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"@机器人 /analyze 600519\"}"
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
# 检查事件类型
|
||||
header = data.get('header', {})
|
||||
event_type = header.get('event_type', '')
|
||||
|
||||
if event_type != 'im.message.receive_v1':
|
||||
logger.debug(f"[Feishu] 忽略非消息事件: {event_type}")
|
||||
return None
|
||||
|
||||
event = data.get('event', {})
|
||||
message_data = event.get('message', {})
|
||||
sender_data = event.get('sender', {})
|
||||
|
||||
# 只处理文本消息
|
||||
message_type = message_data.get('message_type', '')
|
||||
if message_type != 'text':
|
||||
logger.debug(f"[Feishu] 忽略非文本消息: {message_type}")
|
||||
return None
|
||||
|
||||
# 解析消息内容
|
||||
content_str = message_data.get('content', '{}')
|
||||
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, event)
|
||||
mentioned = '@' in raw_content or bool(message_data.get('mentions'))
|
||||
|
||||
# 获取发送者信息
|
||||
sender_id = sender_data.get('sender_id', {})
|
||||
user_id = sender_id.get('open_id', '') or sender_id.get('user_id', '')
|
||||
|
||||
# 获取会话类型
|
||||
chat_type_str = message_data.get('chat_type', '')
|
||||
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 = header.get('create_time', '')
|
||||
try:
|
||||
timestamp = datetime.fromtimestamp(int(create_time) / 1000)
|
||||
except (ValueError, TypeError):
|
||||
timestamp = datetime.now()
|
||||
|
||||
return BotMessage(
|
||||
platform=self.platform_name,
|
||||
message_id=message_data.get('message_id', ''),
|
||||
user_id=user_id,
|
||||
user_name=sender_id.get('user_id', user_id), # 飞书不直接返回用户名
|
||||
chat_id=message_data.get('chat_id', ''),
|
||||
chat_type=chat_type,
|
||||
content=content,
|
||||
raw_content=raw_content,
|
||||
mentioned=mentioned,
|
||||
mentions=[m.get('key', '') for m in message_data.get('mentions', [])],
|
||||
timestamp=timestamp,
|
||||
raw_data=data,
|
||||
)
|
||||
|
||||
def _extract_command(self, text: str, event: Dict) -> str:
|
||||
"""
|
||||
提取命令内容(去除 @机器人)
|
||||
|
||||
飞书的 @用户 格式是:@_user_1
|
||||
"""
|
||||
# 移除 @提及
|
||||
mentions = event.get('message', {}).get('mentions', [])
|
||||
for mention in mentions:
|
||||
key = mention.get('key', '')
|
||||
if key:
|
||||
text = text.replace(key, '')
|
||||
|
||||
# 清理多余空格
|
||||
return ' '.join(text.split())
|
||||
|
||||
def format_response(
|
||||
self,
|
||||
response: BotResponse,
|
||||
message: BotMessage
|
||||
) -> WebhookResponse:
|
||||
"""
|
||||
格式化飞书响应
|
||||
|
||||
飞书 Webhook 只需要返回空响应,实际回复需要调用 API。
|
||||
这里我们返回空响应,然后通过 NotificationService 发送消息。
|
||||
"""
|
||||
# 飞书事件回调只需要返回空 200 响应
|
||||
# 实际的消息回复需要调用飞书 API(通过 NotificationService)
|
||||
|
||||
if response.text:
|
||||
# 通过通知服务发送响应
|
||||
self._send_reply(response, message)
|
||||
|
||||
return WebhookResponse.success()
|
||||
|
||||
def _send_reply(self, response: BotResponse, message: BotMessage) -> None:
|
||||
"""
|
||||
发送回复消息
|
||||
|
||||
通过飞书 API 发送回复(异步,不阻塞 Webhook 响应)
|
||||
"""
|
||||
import threading
|
||||
|
||||
def _send():
|
||||
try:
|
||||
from notification import NotificationService
|
||||
|
||||
notifier = NotificationService()
|
||||
|
||||
# 构建回复内容
|
||||
text = response.text
|
||||
if response.at_user and message.user_id:
|
||||
# 飞书的 @用户 需要使用 open_id
|
||||
text = f"<at user_id=\"{message.user_id}\"></at> {text}"
|
||||
|
||||
# 发送到飞书
|
||||
notifier.send_to_feishu(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu] 发送回复失败: {e}")
|
||||
|
||||
# 异步发送
|
||||
thread = threading.Thread(target=_send, daemon=True)
|
||||
thread.start()
|
||||
548
bot/platforms/feishu_stream.py
Normal file
@@ -0,0 +1,548 @@
|
||||
# -*- 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
|
||||
|
||||
|
||||
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()
|
||||
|
||||
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_id(at_user=True 时需要)
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
# 构建回复内容
|
||||
if at_user and user_id:
|
||||
content = json.dumps({"text": f"<at user_id=\"{user_id}\"></at> {text}"})
|
||||
else:
|
||||
content = json.dumps({"text": text})
|
||||
|
||||
request = ReplyMessageRequest.builder() \
|
||||
.message_id(message_id) \
|
||||
.request_body(
|
||||
ReplyMessageRequestBody.builder()
|
||||
.content(content)
|
||||
.msg_type("text")
|
||||
.build()
|
||||
) \
|
||||
.build()
|
||||
|
||||
response = self._client.im.v1.message.reply(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] 回复消息成功: message_id={message_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu Stream] 回复消息异常: {e}")
|
||||
return False
|
||||
|
||||
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:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
content = json.dumps({"text": text})
|
||||
|
||||
request = CreateMessageRequest.builder() \
|
||||
.receive_id_type(receive_id_type) \
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(chat_id)
|
||||
.content(content)
|
||||
.msg_type("text")
|
||||
.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] 发送消息成功: chat_id={chat_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Feishu Stream] 发送消息异常: {e}")
|
||||
return False
|
||||
|
||||
|
||||
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 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 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
|
||||
@@ -136,6 +136,7 @@ class Config:
|
||||
# 飞书机器人(事件订阅)- 已有 feishu_app_id, feishu_app_secret
|
||||
feishu_verification_token: Optional[str] = None # 事件订阅验证 Token
|
||||
feishu_encrypt_key: Optional[str] = None # 消息加密密钥(可选)
|
||||
feishu_stream_enabled: bool = False # 是否启用 Stream 长连接模式(无需公网IP)
|
||||
|
||||
# 钉钉机器人
|
||||
dingtalk_app_key: Optional[str] = None # 应用 AppKey
|
||||
@@ -256,6 +257,7 @@ class Config:
|
||||
# 飞书机器人
|
||||
feishu_verification_token=os.getenv('FEISHU_VERIFICATION_TOKEN'),
|
||||
feishu_encrypt_key=os.getenv('FEISHU_ENCRYPT_KEY'),
|
||||
feishu_stream_enabled=os.getenv('FEISHU_STREAM_ENABLED', 'false').lower() == 'true',
|
||||
# 钉钉机器人
|
||||
dingtalk_app_key=os.getenv('DINGTALK_APP_KEY'),
|
||||
dingtalk_app_secret=os.getenv('DINGTALK_APP_SECRET'),
|
||||
|
||||
20
docs/bot/feishu-bot-config.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# 飞书机器人配置
|
||||
|
||||
## 创建应用
|
||||
https://open.feishu.cn/document/develop-an-echo-bot/introduction
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 获取密钥
|
||||

|
||||
|
||||
## 发布应用
|
||||

|
||||
|
||||
## 在飞书中打开应用
|
||||

|
||||
|
||||
## 消息交互
|
||||

|
||||
BIN
docs/bot/img_10.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/bot/img_4.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
docs/bot/img_5.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
docs/bot/img_6.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/bot/img_7.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/bot/img_8.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/bot/img_9.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
42
main.py
@@ -895,21 +895,35 @@ def run_full_analysis(
|
||||
|
||||
def start_bot_stream_clients(config: Config) -> None:
|
||||
"""Start bot stream clients when enabled in config."""
|
||||
if not config.dingtalk_stream_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
from bot.platforms import start_dingtalk_stream_background, DINGTALK_STREAM_AVAILABLE
|
||||
if DINGTALK_STREAM_AVAILABLE:
|
||||
if start_dingtalk_stream_background():
|
||||
logger.info("[Main] Dingtalk Stream client started in background.")
|
||||
# 启动钉钉 Stream 客户端
|
||||
if config.dingtalk_stream_enabled:
|
||||
try:
|
||||
from bot.platforms import start_dingtalk_stream_background, DINGTALK_STREAM_AVAILABLE
|
||||
if DINGTALK_STREAM_AVAILABLE:
|
||||
if start_dingtalk_stream_background():
|
||||
logger.info("[Main] Dingtalk Stream client started in background.")
|
||||
else:
|
||||
logger.warning("[Main] Dingtalk Stream client failed to start.")
|
||||
else:
|
||||
logger.warning("[Main] Dingtalk Stream client failed to start.")
|
||||
else:
|
||||
logger.warning("[Main] Dingtalk Stream enabled but SDK is missing.")
|
||||
logger.warning("[Main] Run: pip install dingtalk-stream")
|
||||
except Exception as exc:
|
||||
logger.error(f"[Main] Failed to start Dingtalk Stream client: {exc}")
|
||||
logger.warning("[Main] Dingtalk Stream enabled but SDK is missing.")
|
||||
logger.warning("[Main] Run: pip install dingtalk-stream")
|
||||
except Exception as exc:
|
||||
logger.error(f"[Main] Failed to start Dingtalk Stream client: {exc}")
|
||||
|
||||
# 启动飞书 Stream 客户端
|
||||
if getattr(config, 'feishu_stream_enabled', False):
|
||||
try:
|
||||
from bot.platforms import start_feishu_stream_background, FEISHU_SDK_AVAILABLE
|
||||
if FEISHU_SDK_AVAILABLE:
|
||||
if start_feishu_stream_background():
|
||||
logger.info("[Main] Feishu Stream client started in background.")
|
||||
else:
|
||||
logger.warning("[Main] Feishu Stream client failed to start.")
|
||||
else:
|
||||
logger.warning("[Main] Feishu Stream enabled but SDK is missing.")
|
||||
logger.warning("[Main] Run: pip install lark-oapi")
|
||||
except Exception as exc:
|
||||
logger.error(f"[Main] Failed to start Feishu Stream client: {exc}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
170
notification.py
@@ -228,8 +228,11 @@ class NotificationService:
|
||||
return ', '.join(names)
|
||||
|
||||
def _has_context_channel(self) -> bool:
|
||||
"""判断是否存在基于消息上下文的临时渠道(如钉钉会话)"""
|
||||
return self._extract_dingtalk_session_webhook() is not None
|
||||
"""判断是否存在基于消息上下文的临时渠道(如钉钉会话、飞书会话)"""
|
||||
return (
|
||||
self._extract_dingtalk_session_webhook() is not None
|
||||
or self._extract_feishu_reply_info() is not None
|
||||
)
|
||||
|
||||
def _extract_dingtalk_session_webhook(self) -> Optional[str]:
|
||||
"""从来源消息中提取钉钉会话 Webhook(用于 Stream 模式回复)"""
|
||||
@@ -248,6 +251,22 @@ class NotificationService:
|
||||
session_webhook = raw_data["headers"].get("sessionWebhook")
|
||||
return session_webhook
|
||||
|
||||
def _extract_feishu_reply_info(self) -> Optional[Dict[str, str]]:
|
||||
"""
|
||||
从来源消息中提取飞书回复信息(用于 Stream 模式回复)
|
||||
|
||||
Returns:
|
||||
包含 chat_id 的字典,或 None
|
||||
"""
|
||||
if not isinstance(self._source_message, BotMessage):
|
||||
return None
|
||||
if getattr(self._source_message, "platform", "") != "feishu":
|
||||
return None
|
||||
chat_id = getattr(self._source_message, "chat_id", "")
|
||||
if not chat_id:
|
||||
return None
|
||||
return {"chat_id": chat_id}
|
||||
|
||||
def send_to_context(self, content: str) -> bool:
|
||||
"""
|
||||
向基于消息上下文的渠道发送消息(例如钉钉 Stream 会话)
|
||||
@@ -2343,23 +2362,152 @@ class NotificationService:
|
||||
|
||||
def _send_via_source_context(self, content: str) -> bool:
|
||||
"""
|
||||
使用消息上下文(如钉钉会话 webhook)发送一份报告
|
||||
使用消息上下文(如钉钉/飞书会话)发送一份报告
|
||||
|
||||
主要用于从钉钉 Stream 触发的任务,确保结果能回到触发的会话。
|
||||
主要用于从机器人 Stream 模式触发的任务,确保结果能回到触发的会话。
|
||||
"""
|
||||
success = False
|
||||
|
||||
# 尝试钉钉会话
|
||||
session_webhook = self._extract_dingtalk_session_webhook()
|
||||
if not session_webhook:
|
||||
return False
|
||||
if session_webhook:
|
||||
try:
|
||||
if self._send_dingtalk_chunked(session_webhook, content, max_bytes=20000):
|
||||
logger.info("已通过钉钉会话(Stream)推送报告")
|
||||
success = True
|
||||
else:
|
||||
logger.error("钉钉会话(Stream)推送失败")
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉会话(Stream)推送异常: {e}")
|
||||
|
||||
# 尝试飞书会话
|
||||
feishu_info = self._extract_feishu_reply_info()
|
||||
if feishu_info:
|
||||
try:
|
||||
if self._send_feishu_stream_reply(feishu_info["chat_id"], content):
|
||||
logger.info("已通过飞书会话(Stream)推送报告")
|
||||
success = True
|
||||
else:
|
||||
logger.error("飞书会话(Stream)推送失败")
|
||||
except Exception as e:
|
||||
logger.error(f"飞书会话(Stream)推送异常: {e}")
|
||||
|
||||
return success
|
||||
|
||||
def _send_feishu_stream_reply(self, chat_id: str, content: str) -> bool:
|
||||
"""
|
||||
通过飞书 Stream 模式发送消息到指定会话
|
||||
|
||||
Args:
|
||||
chat_id: 飞书会话 ID
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
是否发送成功
|
||||
"""
|
||||
try:
|
||||
if self._send_dingtalk_chunked(session_webhook, content, max_bytes=20000):
|
||||
logger.info("已通过钉钉会话(Stream)推送报告")
|
||||
return True
|
||||
logger.error("钉钉会话(Stream)推送失败")
|
||||
from bot.platforms.feishu_stream import FeishuReplyClient, FEISHU_SDK_AVAILABLE
|
||||
if not FEISHU_SDK_AVAILABLE:
|
||||
logger.warning("飞书 SDK 不可用,无法发送 Stream 回复")
|
||||
return False
|
||||
|
||||
from config import get_config
|
||||
config = get_config()
|
||||
|
||||
app_id = getattr(config, 'feishu_app_id', None)
|
||||
app_secret = getattr(config, 'feishu_app_secret', None)
|
||||
|
||||
if not app_id or not app_secret:
|
||||
logger.warning("飞书 APP_ID 或 APP_SECRET 未配置")
|
||||
return False
|
||||
|
||||
# 创建回复客户端
|
||||
reply_client = FeishuReplyClient(app_id, app_secret)
|
||||
|
||||
# 飞书文本消息有长度限制,需要分批发送
|
||||
max_bytes = getattr(config, 'feishu_max_bytes', 20000)
|
||||
content_bytes = len(content.encode('utf-8'))
|
||||
|
||||
if content_bytes > max_bytes:
|
||||
return self._send_feishu_stream_chunked(reply_client, chat_id, content, max_bytes)
|
||||
|
||||
return reply_client.send_to_chat(chat_id, content)
|
||||
|
||||
except ImportError as e:
|
||||
logger.error(f"导入飞书 Stream 模块失败: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉会话(Stream)推送异常: {e}")
|
||||
logger.error(f"飞书 Stream 回复异常: {e}")
|
||||
return False
|
||||
|
||||
def _send_feishu_stream_chunked(
|
||||
self,
|
||||
reply_client,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
max_bytes: int
|
||||
) -> bool:
|
||||
"""
|
||||
分批发送长消息到飞书(Stream 模式)
|
||||
|
||||
Args:
|
||||
reply_client: FeishuReplyClient 实例
|
||||
chat_id: 飞书会话 ID
|
||||
content: 完整消息内容
|
||||
max_bytes: 单条消息最大字节数
|
||||
|
||||
Returns:
|
||||
是否全部发送成功
|
||||
"""
|
||||
import time
|
||||
|
||||
def get_bytes(s: str) -> int:
|
||||
return len(s.encode('utf-8'))
|
||||
|
||||
# 按段落或分隔线分割
|
||||
if "\n---\n" in content:
|
||||
sections = content.split("\n---\n")
|
||||
separator = "\n---\n"
|
||||
elif "\n### " in content:
|
||||
parts = content.split("\n### ")
|
||||
sections = [parts[0]] + [f"### {p}" for p in parts[1:]]
|
||||
separator = "\n"
|
||||
else:
|
||||
# 按行分割
|
||||
sections = content.split("\n")
|
||||
separator = "\n"
|
||||
|
||||
chunks = []
|
||||
current_chunk = []
|
||||
current_bytes = 0
|
||||
separator_bytes = get_bytes(separator)
|
||||
|
||||
for section in sections:
|
||||
section_bytes = get_bytes(section) + separator_bytes
|
||||
|
||||
if current_bytes + section_bytes > max_bytes:
|
||||
if current_chunk:
|
||||
chunks.append(separator.join(current_chunk))
|
||||
current_chunk = [section]
|
||||
current_bytes = section_bytes
|
||||
else:
|
||||
current_chunk.append(section)
|
||||
current_bytes += section_bytes
|
||||
|
||||
if current_chunk:
|
||||
chunks.append(separator.join(current_chunk))
|
||||
|
||||
# 发送每个分块
|
||||
success = True
|
||||
for i, chunk in enumerate(chunks):
|
||||
if i > 0:
|
||||
time.sleep(0.5) # 避免请求过快
|
||||
|
||||
if not reply_client.send_to_chat(chat_id, chunk):
|
||||
success = False
|
||||
logger.error(f"飞书 Stream 分块 {i+1}/{len(chunks)} 发送失败")
|
||||
|
||||
return success
|
||||
|
||||
def send(self, content: str) -> bool:
|
||||
"""
|
||||
|
||||
15
webui.py
@@ -85,6 +85,21 @@ def _start_bot_stream_clients() -> None:
|
||||
except Exception as e:
|
||||
logger.error(f"[WebUI] 启动钉钉 Stream 客户端失败: {e}")
|
||||
|
||||
# 飞书 Stream 模式
|
||||
if getattr(config, 'feishu_stream_enabled', False):
|
||||
try:
|
||||
from bot.platforms import start_feishu_stream_background, FEISHU_SDK_AVAILABLE
|
||||
if FEISHU_SDK_AVAILABLE:
|
||||
if start_feishu_stream_background():
|
||||
logger.info("[WebUI] 飞书 Stream 客户端已在后台启动")
|
||||
else:
|
||||
logger.warning("[WebUI] 飞书 Stream 客户端启动失败")
|
||||
else:
|
||||
logger.warning("[WebUI] 飞书 Stream 模式已启用但 SDK 未安装")
|
||||
logger.warning("[WebUI] 请运行: pip install lark-oapi")
|
||||
except Exception as e:
|
||||
logger.error(f"[WebUI] 启动飞书 Stream 客户端失败: {e}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
|
||||