fix: 飞书长连接通知

This commit is contained in:
Krane
2026-01-22 20:24:34 +08:00
parent 31b3899fc2
commit dd923ad67e
16 changed files with 805 additions and 308 deletions

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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()

View 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_idat_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

View File

@@ -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'),

View File

@@ -0,0 +1,20 @@
# 飞书机器人配置
## 创建应用
https://open.feishu.cn/document/develop-an-echo-bot/introduction
![img_6.png](img_6.png)
![img_8.png](img_8.png)
## 获取密钥
![img_7.png](img_7.png)
## 发布应用
![img_5.png](img_5.png)
## 在飞书中打开应用
![img_9.png](img_9.png)
## 消息交互
![img_10.png](img_10.png)

BIN
docs/bot/img_10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

BIN
docs/bot/img_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
docs/bot/img_5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
docs/bot/img_6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
docs/bot/img_7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/bot/img_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

BIN
docs/bot/img_9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

42
main.py
View File

@@ -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:

View File

@@ -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:
"""

View File

@@ -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:
"""