feat: 新增飞书长连接模式,无需公网域名

## 🚀 重大更新

### 飞书集成升级
-  迁移到飞书官方 SDK 的事件订阅 2.0(长连接模式)
-  无需公网域名和 webhook 配置
-  支持内网部署
-  自动重连机制

### 核心功能优化
-  优化群聊隔离机制(每个用户在每个群独立会话)
-  增强日志输出(emoji 标记便于快速识别)
-  完善错误处理和异常恢复
-  添加 SSL 证书问题解决方案

### 新增文件
- `src/integrations/feishu_longconn_service.py` - 飞书长连接服务
- `start_feishu_bot.py` - 启动脚本
- `test_feishu_connection.py` - 连接诊断工具
- `docs/FEISHU_LONGCONN.md` - 详细使用文档
- `README.md` - 项目说明文档

### 技术改进
- 添加 lark-oapi==1.3.5 官方 SDK
- 升级 certifi 包以支持 SSL 验证
- 优化配置加载逻辑
- 改进会话管理机制

### 文档更新
- 新增飞书长连接模式完整文档
- 更新快速开始指南
- 添加常见问题解答(SSL、权限、部署等)
- 完善架构说明和技术栈介绍

## 📝 使用方式

启动飞书长连接服务(无需公网域名):
```bash
python3 start_feishu_bot.py
```

详见:docs/FEISHU_LONGCONN.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
zhaojie
2026-02-11 14:10:18 +08:00
parent f5acb05e61
commit e3a0396567
18 changed files with 1501 additions and 112 deletions

View File

@@ -0,0 +1,269 @@
# -*- coding: utf-8 -*-
"""
飞书长连接服务(基于官方 SDK- 修正版
使用事件订阅 2.0 - 无需公网域名和 webhook 配置
"""
import logging
import json
from typing import Optional
import lark_oapi as lark
from lark_oapi.api.im.v1 import P2ImMessageReceiveV1, ReplyMessageRequest, ReplyMessageRequestBody
from src.config.unified_config import get_config
from src.web.service_manager import service_manager
logger = logging.getLogger(__name__)
class FeishuLongConnService:
"""飞书长连接服务 - 使用官方 SDK"""
def __init__(self):
"""初始化飞书长连接服务"""
config = get_config()
self.app_id = config.feishu.app_id
self.app_secret = config.feishu.app_secret
logger.info("🚀 初始化飞书长连接服务...")
logger.info(f" - App ID: {self.app_id}")
logger.info(f" - App Secret: {self.app_secret[:10]}...")
# 创建飞书客户端
self.client = lark.Client.builder() \
.app_id(self.app_id) \
.app_secret(self.app_secret) \
.log_level(lark.LogLevel.DEBUG) \
.build()
logger.info("✅ 飞书客户端创建成功")
# 创建事件处理器
self.event_handler = lark.EventDispatcherHandler.builder(
"", "" # 长连接模式不需要 verification_token 和 encrypt_key
).register_p2_im_message_receive_v1(self._handle_message) \
.build()
logger.info("✅ 飞书事件处理器创建成功")
logger.info("✅ 飞书长连接服务初始化完成")
def _handle_message(self, data: P2ImMessageReceiveV1) -> None:
"""
处理接收到的消息事件
Args:
data: 飞书消息事件数据
"""
logger.info("=" * 80)
logger.info("📨 收到飞书消息事件!")
logger.info("=" * 80)
try:
# 提取消息信息
event = data.event
message = event.message
message_id = message.message_id
chat_id = message.chat_id
message_type = message.message_type
content = message.content
sender = event.sender
logger.info(f"📋 消息详情:")
logger.info(f" - 消息ID: {message_id}")
logger.info(f" - 群聊ID: {chat_id}")
logger.info(f" - 发送者ID: {sender.sender_id.user_id}")
logger.info(f" - 消息类型: {message_type}")
logger.info(f" - 原始内容: {content}")
# 只处理文本消息
if message_type != "text":
logger.info(f"⏭️ 跳过非文本消息类型: {message_type}")
return
# 解析消息内容
try:
content_json = json.loads(content)
text_content = content_json.get("text", "").strip()
except json.JSONDecodeError as e:
logger.error(f"❌ 解析消息内容失败: {e}")
return
logger.info(f"📝 文本内容: {text_content}")
# 移除 @机器人 的部分
mentions = message.mentions
if mentions:
logger.info(f"👥 检测到 {len(mentions)} 个提及")
for mention in mentions:
mention_name = mention.name
if mention_name:
logger.info(f" - @{mention_name}")
# 尝试多种 @ 格式
for prefix in [f"@{mention_name}", f"@{mention_name} "]:
if text_content.startswith(prefix):
text_content = text_content[len(prefix):].strip()
break
if not text_content:
logger.warning(f"⚠️ 移除@后内容为空,回复提示")
self._reply_message(message_id, "您好!请问有什么可以帮助您的吗?")
return
logger.info(f"✅ 清理后内容: {text_content}")
# 获取发送者ID
sender_id = sender.sender_id.user_id
# 构造会话用户ID群聊隔离
# 格式: feishu_群聊ID_用户ID
session_user_id = f"feishu_{chat_id}_{sender_id}"
logger.info(f"🔑 会话标识: {session_user_id}")
# 获取或创建会话
chat_manager = service_manager.get_chat_manager()
active_sessions = chat_manager.get_active_sessions()
session_id = None
for session in active_sessions:
if session.get('user_id') == session_user_id:
session_id = session.get('session_id')
logger.info(f"✅ 找到已有会话: {session_id}")
break
# 如果没有会话,创建新会话
if not session_id:
session_id = chat_manager.create_session(
user_id=session_user_id,
work_order_id=None
)
logger.info(f"🆕 创建新会话: {session_id}")
# 调用实时对话接口处理消息
logger.info(f"🤖 调用 TSP Assistant 处理消息...")
response_data = chat_manager.process_message(
session_id=session_id,
user_message=text_content,
ip_address=None,
invocation_method="feishu_longconn"
)
logger.info(f"📊 处理结果: {response_data.get('success')}")
# 提取回复
if response_data.get("success"):
reply_text = response_data.get("response") or response_data.get("content", "抱歉,我暂时无法回答这个问题。")
else:
error_msg = response_data.get('error', '未知错误')
reply_text = f"处理消息时出错: {error_msg}"
logger.error(f"❌ 处理失败: {error_msg}")
# 确保回复是字符串
if isinstance(reply_text, dict):
reply_text = reply_text.get('content', str(reply_text))
if not isinstance(reply_text, str):
reply_text = str(reply_text)
logger.info(f"📤 准备发送回复 (长度: {len(reply_text)})")
logger.info(f" 内容预览: {reply_text[:100]}...")
# 发送回复
self._reply_message(message_id, reply_text)
logger.info("=" * 80)
logger.info("✅ 消息处理完成")
logger.info("=" * 80)
except Exception as e:
logger.error(f"❌ 处理消息时发生错误: {e}", exc_info=True)
# 尝试发送错误提示
try:
if 'message_id' in locals():
self._reply_message(message_id, "抱歉,系统遇到了一些问题,请稍后重试。")
except:
pass
def _reply_message(self, message_id: str, content: str) -> bool:
"""
回复消息
Args:
message_id: 消息ID
content: 回复内容
Returns:
是否成功
"""
try:
logger.info(f"📧 发送回复到消息 {message_id}")
# 转义 JSON 特殊字符
content_escaped = content.replace('"', '\\"').replace('\n', '\\n')
# 构造回复请求
request = ReplyMessageRequest.builder() \
.message_id(message_id) \
.request_body(ReplyMessageRequestBody.builder()
.msg_type("text")
.content(f'{{"text":"{content_escaped}"}}')
.build()) \
.build()
# 发送回复
response = self.client.im.v1.message.reply(request)
if not response.success():
logger.error(f"❌ 回复失败: {response.code} - {response.msg}")
return False
logger.info(f"✅ 回复成功: {message_id}")
return True
except Exception as e:
logger.error(f"❌ 回复消息时发生错误: {e}", exc_info=True)
return False
def start(self):
"""
启动长连接客户端
这个方法会阻塞当前线程,持续监听飞书事件
"""
logger.info("=" * 80)
logger.info("🚀 启动飞书长连接客户端")
logger.info("=" * 80)
logger.info(f"📋 配置信息:")
logger.info(f" - App ID: {self.app_id}")
logger.info(f" - 模式: 事件订阅 2.0(长连接)")
logger.info(f" - 优势: 无需公网域名和 webhook 配置")
logger.info("=" * 80)
logger.info("💡 等待消息中... (按 Ctrl+C 停止)")
logger.info("=" * 80)
try:
# 创建长连接客户端
cli = lark.ws.Client(self.app_id, self.app_secret, event_handler=self.event_handler)
logger.info("🔌 正在建立与飞书服务器的连接...")
# 启动长连接(会阻塞)
cli.start()
except KeyboardInterrupt:
logger.info("")
logger.info("⏹️ 用户中断,停止飞书长连接客户端")
except Exception as e:
logger.error(f"❌ 飞书长连接客户端异常: {e}", exc_info=True)
raise
# 全局服务实例
_feishu_longconn_service = None
def get_feishu_longconn_service() -> FeishuLongConnService:
"""获取飞书长连接服务单例"""
global _feishu_longconn_service
if _feishu_longconn_service is None:
_feishu_longconn_service = FeishuLongConnService()
return _feishu_longconn_service