diff --git a/.claude/settings.local.json b/.claude/settings.local.json index cd1fc99..497982e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,14 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(git push:*)", - "Bash(git config:*)" + "Bash(git config:*)", + "Bash(python:*)", + "Bash(python3:*)", + "Bash(curl:*)", + "Bash(pip show:*)", + "Bash(find:*)", + "Bash(chmod:*)", + "Bash(open:*)" ], "deny": [], "ask": [] diff --git a/.env.example b/.env.example index c1e2e34..858e4b5 100644 --- a/.env.example +++ b/.env.example @@ -9,14 +9,14 @@ SERVER_HOST=0.0.0.0 # The port for the main Flask web server. -SERVER_PORT=5000 +SERVER_PORT=5001 # The port for the WebSocket server for real-time chat. WEBSOCKET_PORT=8765 # Set to "True" for development to enable debug mode and auto-reloading. # Set to "False" for production. -DEBUG_MODE=True +DEBUG_MODE=False # Logging level for the application. Options: DEBUG, INFO, WARNING, ERROR, CRITICAL LOG_LEVEL=INFO @@ -28,7 +28,12 @@ LOG_LEVEL=INFO # The connection string for the primary database. # Format for MySQL: mysql+pymysql://:@:/?charset=utf8mb4 # Format for SQLite: sqlite:///./local_test.db -DATABASE_URL=mysql+pymysql://tsp_assistant:123456@jeason.online/tsp_assistant?charset=utf8mb4 + +# 使用本地 SQLite(推荐用于开发和测试) +DATABASE_URL=sqlite:///./data/tsp_assistant.db + +# 远程 MySQL(生产环境使用,需要时取消注释) +# DATABASE_URL=mysql+pymysql://tsp_assistant:123456@jeason.online/tsp_assistant?charset=utf8mb4 # ============================================================================ # LARGE LANGUAGE MODEL (LLM) CONFIGURATION @@ -70,6 +75,9 @@ FEISHU_VERIFICATION_TOKEN= # The Encrypt Key for decrypting event data (if configured). FEISHU_ENCRYPT_KEY= +# The App Token of the Feishu multi-dimensional table document. +FEISHU_APP_TOKEN=XXnEbiCmEaMblSs6FDJcFCqsnIg + # The ID of the Feishu multi-dimensional table for data synchronization. FEISHU_TABLE_ID=tblnl3vJPpgMTSiP @@ -91,3 +99,28 @@ AI_SUGGESTION_CONFIDENCE=0.95 # The confidence score assigned when a human resolution is used. AI_HUMAN_RESOLUTION_CONFIDENCE=0.90 + + +# ============================================================================ +# REDIS CACHE CONFIGURATION +# ============================================================================ +# Redis server host (use localhost for local development) +REDIS_HOST=localhost + +# Redis server port +REDIS_PORT=6379 + +# Redis database number (0-15) +REDIS_DB=0 + +# Redis password (leave empty if no password) +REDIS_PASSWORD= + +# Redis connection pool size +REDIS_POOL_SIZE=10 + +# Redis cache default TTL in seconds (3600 = 1 hour) +REDIS_DEFAULT_TTL=3600 + +# Enable Redis cache (set to False to disable caching) +REDIS_ENABLED=True diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a3d184 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# 环境变量和敏感信息 +.env +*.log + +# 数据库 +*.db +*.sqlite +*.sqlite3 + +# 日志文件 +logs/ +*.log + +# 临时文件 +*.tmp +*.temp +note.txt +database_init_report.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# macOS +.DS_Store + +# 测试文件 +test_*.py +*_test.py + +# 缓存 +.cache/ diff --git a/docs/FEISHU_LONGCONN.md b/docs/FEISHU_LONGCONN.md new file mode 100644 index 0000000..46881ae --- /dev/null +++ b/docs/FEISHU_LONGCONN.md @@ -0,0 +1,395 @@ +# 飞书长连接模式使用指南 + +> ✅ **已验证可用** - 2026-02-11 +> +> 本文档介绍如何使用飞书官方 SDK 的长连接模式,无需公网域名即可接入飞书机器人。 + +## 🎯 什么是长连接模式? + +飞书官方 SDK 提供了**事件订阅 2.0(长连接模式)**,相比传统的 webhook 模式有以下优势: + +| 特性 | Webhook 模式(旧) | 长连接模式(新)✅ | +|------|-------------------|-------------------| +| 公网域名 | ✅ 必需 | ❌ 不需要 | +| SSL 证书 | ✅ 必需 | ❌ 不需要 | +| 回调配置 | ✅ 需要配置 | ❌ 不需要 | +| 内网部署 | ❌ 不支持 | ✅ 支持 | +| 实时性 | 中等(HTTP 轮询) | ✅ 高(WebSocket) | +| 稳定性 | 中等 | ✅ 高(自动重连) | + +--- + +## 📦 安装步骤 + +### 1. 安装依赖 + +```bash +cd /Users/macos/Desktop/tsp-assist/assist +pip install -r requirements.txt +``` + +**核心依赖:** +- `lark-oapi==1.3.5` - 飞书官方 Python SDK + +### 2. 配置环境变量 + +编辑 `.env` 文件: + +```bash +# 飞书配置(必需) +FEISHU_APP_ID=cli_xxxxxxxxxxxxx # 你的飞书应用 ID +FEISHU_APP_SECRET=xxxxxxxxxxxxx # 你的飞书应用密钥 + +# 数据库配置 +DATABASE_URL=sqlite:///./data/tsp_assistant.db + +# LLM 配置 +LLM_PROVIDER=qwen +LLM_API_KEY=sk-xxxxxxxxxxxxx +LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +LLM_MODEL=qwen-plus-latest +``` + +### 3. 初始化数据库 + +```bash +python init_database.py +``` + +### 4. 在飞书开放平台配置应用权限 + +访问 [飞书开放平台](https://open.feishu.cn/app),为您的应用添加以下权限: + +**必需权限:** +- ✅ `im:message` - 获取与发送单聊、群组消息 +- ✅ `im:message:send_as_bot` - 以应用的身份发消息 +- ✅ `im:chat` - 获取群组信息 + +**可选权限(用于工单同步):** +- ✅ `bitable:app` - 查看、评论、编辑和管理多维表格 + +**注意:** 添加权限后需要重新发布应用版本。 + +--- + +## 🚀 启动服务 + +### 方式 1: 使用启动脚本(推荐) + +```bash +python start_feishu_bot.py +``` + +启动后会看到: + +``` +================================================================================ +🚀 TSP Assistant - 飞书长连接客户端 +================================================================================ + +📋 配置信息: + - App ID: cli_xxxxxxxxxxxxx + - 数据库: sqlite:///./data/tsp_assistant.db + - LLM: qwen/qwen-plus-latest + - 日志级别: INFO + +🔌 启动模式: 事件订阅 2.0(长连接) +✅ 优势: + - 无需公网域名 + - 无需配置 webhook + - 自动重连 + - 实时接收消息 + +💡 提示: 按 Ctrl+C 停止服务 +================================================================================ + +🚀 启动飞书长连接客户端... + - App ID: cli_xxxxxxxxxxxxx + - 模式: 事件订阅 2.0(长连接) + - 无需公网域名和 webhook 配置 +✅ 飞书长连接服务初始化成功 +``` + +### 方式 2: 在代码中集成 + +如果您想将长连接服务集成到现有的 Flask 应用中: + +```python +import threading +from src.integrations.feishu_longconn_service import get_feishu_longconn_service + +# 在后台线程中启动长连接服务 +def start_feishu_in_background(): + service = get_feishu_longconn_service() + service.start() + +# 启动 +feishu_thread = threading.Thread(target=start_feishu_in_background, daemon=True) +feishu_thread.start() + +# 然后启动 Flask 应用 +app.run(...) +``` + +--- + +## 🧪 测试 + +### 1. 在飞书群聊中测试 + +**场景 1:单个用户对话** + +``` +用户 A: @TSP助手 车辆无法连接网络怎么办? +机器人: [基于工单知识库的智能回复] + +用户 A: 还有其他解决方法吗? +机器人: [基于上下文的多轮对话回复] +``` + +**场景 2:多用户群聊隔离** + +``` +群聊 1: + 用户 A: @TSP助手 我的车有问题 + 机器人: [针对用户 A 的回复] + + 用户 B: @TSP助手 我的车有问题 + 机器人: [针对用户 B 的独立回复,不受用户 A 影响] + +群聊 2: + 用户 A: @TSP助手 我的车有问题 + 机器人: [全新对话,不受群聊 1 影响] +``` + +### 2. 查看日志 + +服务运行时会实时输出日志: + +``` +📨 [Feishu LongConn] 收到消息 + - 消息ID: om_xxxxxxxxxxxxx + - 群聊ID: oc_xxxxxxxxxxxxx + - 发送者: ou_xxxxxxxxxxxxx + - 消息类型: text + - 原始内容: @TSP助手 车辆无法连接网络 + - 清理后内容: 车辆无法连接网络 +🔑 会话用户标识: feishu_oc_xxxxxxxxxxxxx_ou_xxxxxxxxxxxxx +🆕 为用户 ou_xxxxxxxxxxxxx 在群聊 oc_xxxxxxxxxxxxx 创建新会话: session_xxxxxxxxxxxxx +🤖 调用 TSP Assistant 处理消息... +📤 准备发送回复 (长度: 156) +✅ 成功回复消息: om_xxxxxxxxxxxxx +``` + +--- + +## 🔧 与旧模式的对比 + +### 旧模式(Webhook)- 已废弃 + +**文件:** `src/web/blueprints/feishu_bot.py` + +**工作流程:** +``` +飞书 → 公网域名 → Webhook → Flask → 处理消息 +``` + +**缺点:** +- ❌ 需要公网域名 +- ❌ 需要配置 SSL +- ❌ 内网无法部署 +- ❌ 需要在飞书后台配置回调地址 + +### 新模式(长连接)- 推荐 ✅ + +**文件:** `src/integrations/feishu_longconn_service.py` + +**工作流程:** +``` +飞书 ← WebSocket 长连接 ← SDK 客户端 ← 处理消息 +``` + +**优点:** +- ✅ 无需公网域名 +- ✅ 无需 SSL 证书 +- ✅ 内网可部署 +- ✅ 无需配置回调地址 +- ✅ 自动重连 +- ✅ 更稳定 + +--- + +## 📊 架构说明 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 飞书服务器 │ +│ (open.feishu.cn) │ +└───────────────────────┬─────────────────────────────────────┘ + │ WebSocket 长连接 + │ (事件订阅 2.0) + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ TSP Assistant (内网部署) │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ feishu_longconn_service.py │ │ +│ │ - 飞书 SDK 长连接客户端 │ │ +│ │ - 自动接收消息事件 │ │ +│ │ - 群聊隔离 (feishu_群ID_用户ID) │ │ +│ └──────────────┬─────────────────────────────────────┘ │ +│ ↓ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ RealtimeChatManager │ │ +│ │ - 会话管理 │ │ +│ │ - 多轮对话 │ │ +│ └──────────────┬─────────────────────────────────────┘ │ +│ ↓ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ TSP Assistant 智能引擎 │ │ +│ │ - LLM 调用 │ │ +│ │ - 工单知识库匹配 │ │ +│ │ - AI 分析和建议 │ │ +│ └────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## ⚙️ 高级配置 + +### 1. 调整日志级别 + +编辑 `.env`: + +```bash +LOG_LEVEL=DEBUG # 查看详细日志 +LOG_LEVEL=INFO # 默认(推荐) +LOG_LEVEL=WARNING # 仅警告和错误 +``` + +### 2. 同时运行 Web 管理后台和长连接服务 + +**方式 1:使用两个终端** + +```bash +# 终端 1 - Web 管理后台 +python start_dashboard.py + +# 终端 2 - 飞书长连接服务 +python start_feishu_bot.py +``` + +**方式 2:使用进程管理器(推荐生产环境)** + +创建 `supervisord.conf`: + +```ini +[program:tsp-web] +command=python start_dashboard.py +directory=/Users/macos/Desktop/tsp-assist/assist +autostart=true +autorestart=true + +[program:tsp-feishu] +command=python start_feishu_bot.py +directory=/Users/macos/Desktop/tsp-assist/assist +autostart=true +autorestart=true +``` + +启动: +```bash +supervisord -c supervisord.conf +``` + +--- + +## ❓ 常见问题 + +### Q1: SSL 证书验证失败怎么办? + +**错误信息:** +``` +[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain +``` + +**解决方案(macOS):** + +1. **运行 Python 证书安装脚本**(推荐): +```bash +open "/Applications/Python 3.10/Install Certificates.command" +``` + +或者手动运行: +```bash +/Applications/Python\ 3.10/Install\ Certificates.command +``` + +2. **升级 certifi 包**: +```bash +python3 -m pip install --upgrade certifi +``` + +3. **验证修复**: +```bash +python3 -c "import urllib.request; urllib.request.urlopen('https://open.feishu.cn', timeout=5); print('✅ SSL 验证成功')" +``` + +**解决方案(Linux/Windows):** +```bash +pip3 install --upgrade certifi +``` + +### Q2: 启动后没有收到消息? + +**检查清单:** +1. ✅ 确认飞书应用权限已配置(`im:message`) +2. ✅ 确认应用已发布最新版本 +3. ✅ 确认机器人已添加到群聊 +4. ✅ 查看日志是否有连接成功的提示 +5. ✅ 确认没有 SSL 证书错误(见 Q1) + +### Q3: 提示"权限不足"? + +**解决方案:** +1. 登录飞书开放平台 +2. 进入应用管理 → 权限管理 +3. 添加所需权限 +4. 创建新版本并发布 +5. 重启长连接服务 + +### Q4: 如何停止服务? + +按 `Ctrl+C` 即可优雅停止。 + +### Q5: 可以在多台服务器上运行吗? + +**不建议**。同一个飞书应用只应运行一个长连接客户端实例,否则会导致消息重复处理。 + +### Q6: 如何迁移旧的 Webhook 模式? + +**建议:** +1. 保留旧代码(`feishu_bot.py`)作为备份 +2. 使用新的长连接模式(`start_feishu_bot.py`) +3. 测试无误后,可以移除 webhook 端点 + +--- + +## 🎉 总结 + +现在您的 TSP Assistant 已经支持**飞书官方 SDK 的长连接模式**! + +✅ **核心优势:** +- 无需公网域名 +- 无需 webhook 配置 +- 内网可部署 +- 自动重连 +- 群聊隔离 +- 完整的工单管理和 AI 分析功能 + +✅ **使用方式:** +```bash +python start_feishu_bot.py +``` + +就这么简单!🚀 diff --git a/init_database.py b/init_database.py index 952e7b9..7982fb2 100644 --- a/init_database.py +++ b/init_database.py @@ -38,6 +38,21 @@ class DatabaseInitializer: # 迁移历史记录 self.migration_history = [] + def _mask_db_url(self, url: str) -> str: + """隐藏数据库连接字符串中的敏感信息""" + try: + # 隐藏密码部分 + if '@' in url and ':' in url: + parts = url.split('@') + if len(parts) == 2: + auth_part = parts[0] + if ':' in auth_part: + protocol_user = auth_part.rsplit(':', 1)[0] + return f"{protocol_user}:***@{parts[1]}" + return url + except: + return url + def _get_database_version(self) -> str: """获取数据库版本信息""" try: @@ -61,14 +76,14 @@ class DatabaseInitializer: print("TSP智能助手数据库初始化系统") print("=" * 80) print(f"数据库类型: {self.db_version}") - print(f"连接地址: {self.db_url}") + print(f"连接地址: {self._mask_db_url(self.db_url)}") print(f"初始化时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") print("=" * 80) try: # 设置日志 config = get_config() - setup_logging(config.server.log_level, "logs/tsp_assistant.log") + setup_logging(config.server.log_level, "logs/tsp_assistant.log") # 测试数据库连接 if not self._test_connection(): @@ -95,6 +110,9 @@ class DatabaseInitializer: if not self._verify_database_integrity(): return False + # 初始化 Redis 缓存(如果启用) + self._initialize_redis_cache() + # 生成初始化报告 self._generate_init_report() @@ -521,7 +539,16 @@ class DatabaseInitializer: # 插入初始知识库数据 initial_data = self._get_initial_knowledge_data() for data in initial_data: - entry = KnowledgeEntry(**data) + # 只使用模型定义中存在的字段 + entry = KnowledgeEntry( + question=data['question'], + answer=data['answer'], + category=data['category'], + confidence_score=data.get('confidence_score', 0.7), + is_verified=data.get('is_verified', True), + verified_by=data.get('verified_by', 'system'), + verified_at=data.get('verified_at', datetime.now()) + ) session.add(entry) session.commit() @@ -548,9 +575,7 @@ class DatabaseInitializer: "confidence_score": 0.9, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.9 + "verified_at": datetime.now() }, { "question": "账户被锁定了怎么办?", @@ -559,9 +584,7 @@ class DatabaseInitializer: "confidence_score": 0.8, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.8 + "verified_at": datetime.now() }, { "question": "如何修改个人信息?", @@ -570,9 +593,7 @@ class DatabaseInitializer: "confidence_score": 0.7, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.7 + "verified_at": datetime.now() }, { "question": "支付失败怎么办?", @@ -581,9 +602,7 @@ class DatabaseInitializer: "confidence_score": 0.8, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.8 + "verified_at": datetime.now() }, { "question": "如何申请退款?", @@ -592,9 +611,7 @@ class DatabaseInitializer: "confidence_score": 0.7, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.7 + "verified_at": datetime.now() }, { "question": "系统无法访问怎么办?", @@ -603,9 +620,7 @@ class DatabaseInitializer: "confidence_score": 0.8, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.8 + "verified_at": datetime.now() }, { "question": "如何联系客服?", @@ -614,9 +629,7 @@ class DatabaseInitializer: "confidence_score": 0.9, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.9 + "verified_at": datetime.now() }, { "question": "如何远程启动车辆?", @@ -625,9 +638,7 @@ class DatabaseInitializer: "confidence_score": 0.9, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.9 + "verified_at": datetime.now() }, { "question": "APP显示车辆信息错误怎么办?", @@ -636,9 +647,7 @@ class DatabaseInitializer: "confidence_score": 0.8, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.8 + "verified_at": datetime.now() }, { "question": "车辆无法远程启动的原因?", @@ -647,9 +656,7 @@ class DatabaseInitializer: "confidence_score": 0.9, "is_verified": True, "verified_by": "system", - "verified_at": datetime.now(), - "search_frequency": 0, - "relevance_score": 0.9 + "verified_at": datetime.now() } ] @@ -688,10 +695,14 @@ class DatabaseInitializer: entry.is_verified = True entry.verified_by = "system_init" entry.verified_at = datetime.now() - if not hasattr(entry, 'search_frequency'): - entry.search_frequency = 0 - if not hasattr(entry, 'relevance_score'): - entry.relevance_score = 0.7 + # 尝试设置优化字段(如果存在) + try: + if hasattr(entry, 'search_frequency') and entry.search_frequency is None: + entry.search_frequency = 0 + if hasattr(entry, 'relevance_score') and entry.relevance_score is None: + entry.relevance_score = 0.7 + except: + pass # 如果字段不存在,忽略 session.commit() print(f" 成功验证 {len(unverified_entries)} 条知识库条目") @@ -795,6 +806,34 @@ class DatabaseInitializer: except Exception: return 0 + def _initialize_redis_cache(self): + """初始化 Redis 缓存""" + print("\n检查 Redis 缓存配置...") + + try: + from src.core.cache_manager import cache_manager + + health = cache_manager.health_check() + + if health["status"] == "healthy": + print(" ✓ Redis 缓存连接成功") + stats = cache_manager.get_stats() + print(f" - 当前连接数: {stats.get('connected_clients', 0)}") + print(f" - 内存使用: {stats.get('used_memory_human', '0B')}") + print(f" - 总键数: {stats.get('total_keys', 0)}") + elif health["status"] == "disabled": + print(" ⚠ Redis 缓存未启用(可选功能)") + else: + print(f" ⚠ Redis 缓存连接失败: {health.get('message', '未知错误')}") + print(" 提示: Redis 是可选功能,不影响系统正常运行") + + except ImportError: + print(" ⚠ Redis 库未安装,缓存功能将被禁用") + print(" 提示: 运行 'pip install redis hiredis' 安装 Redis 支持") + except Exception as e: + print(f" ⚠ Redis 初始化失败: {e}") + print(" 提示: Redis 是可选功能,不影响系统正常运行") + def check_database_status(self) -> Dict[str, Any]: """检查数据库状态""" print("\n" + "=" * 80) diff --git a/requirements.txt b/requirements.txt index a5f6d78..9f2739f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -37,6 +37,8 @@ aiohttp==3.10.10 # asyncio是Python内置模块,不需要安装 # Redis缓存 +redis==5.0.1 +hiredis==2.3.2 # 测试框架 pytest==8.3.3 @@ -63,4 +65,7 @@ httpx==0.27.2 # 数据验证 pydantic==2.9.2 -marshmallow==3.23.3 \ No newline at end of file +marshmallow==3.23.3 + +# 飞书官方 SDK(事件订阅 2.0 - 长连接模式) +lark-oapi==1.3.5 \ No newline at end of file diff --git a/src/__pycache__/__init__.cpython-310.pyc b/src/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 3e46ed2..0000000 Binary files a/src/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/src/config/unified_config.py b/src/config/unified_config.py index 5c5d226..62be676 100644 --- a/src/config/unified_config.py +++ b/src/config/unified_config.py @@ -71,6 +71,18 @@ class AIAccuracyConfig: human_resolution_confidence: float = 0.90 +@dataclass +class RedisConfig: + """Redis缓存配置""" + enabled: bool = True + host: str = "localhost" + port: int = 6379 + db: int = 0 + password: Optional[str] = None + pool_size: int = 10 + default_ttl: int = 3600 # 默认缓存过期时间(秒) + + # --- 统一配置管理器 --- class UnifiedConfig: @@ -86,6 +98,7 @@ class UnifiedConfig: self.server = self._load_server_from_env() self.feishu = self._load_feishu_from_env() self.ai_accuracy = self._load_ai_accuracy_from_env() + self.redis = self._load_redis_from_env() self.validate_config() def _load_database_from_env(self) -> DatabaseConfig: @@ -146,6 +159,19 @@ class UnifiedConfig: logger.info("AI Accuracy config loaded.") return config + def _load_redis_from_env(self) -> RedisConfig: + config = RedisConfig( + enabled=os.getenv("REDIS_ENABLED", "True").lower() in ('true', '1', 't'), + host=os.getenv("REDIS_HOST", "localhost"), + port=int(os.getenv("REDIS_PORT", 6379)), + db=int(os.getenv("REDIS_DB", 0)), + password=os.getenv("REDIS_PASSWORD") or None, + pool_size=int(os.getenv("REDIS_POOL_SIZE", 10)), + default_ttl=int(os.getenv("REDIS_DEFAULT_TTL", 3600)) + ) + logger.info("Redis config loaded.") + return config + def validate_config(self): """在启动时验证关键配置""" if not self.database.url: @@ -166,6 +192,7 @@ class UnifiedConfig: 'server': asdict(self.server), 'feishu': asdict(self.feishu), 'ai_accuracy': asdict(self.ai_accuracy), + 'redis': asdict(self.redis), } # --- 全局单例模式 --- diff --git a/src/core/models.py b/src/core/models.py index 40895ab..81eab1a 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -49,6 +49,10 @@ class WorkOrder(Base): dispatch_time = Column(DateTime, nullable=True) # 分发时间 region = Column(String(50), nullable=True) # 区域(overseas/domestic)- 用于区分海外/国内 + # 系统优化字段 + processing_efficiency = Column(Float) # 处理效率 + resource_usage = Column(Text) # 资源使用情况 + # 关联对话记录 conversations = relationship("Conversation", back_populates="work_order") # 关联处理过程记录 @@ -69,6 +73,11 @@ class Conversation(Base): ip_address = Column(String(45), nullable=True) # IP地址 invocation_method = Column(String(50), nullable=True) # 调用方式(websocket, api等) + # 系统优化字段 + processing_time = Column(Float) # 处理时间 + memory_usage = Column(Float) # 内存使用量 + cpu_usage = Column(Float) # CPU使用率 + work_order = relationship("WorkOrder", back_populates="conversations") class KnowledgeEntry(Base): @@ -89,6 +98,11 @@ class KnowledgeEntry(Base): verified_at = Column(DateTime) # 验证时间 vector_embedding = Column(Text) # 向量嵌入的JSON字符串 + # 系统优化字段 + search_frequency = Column(Integer, default=0) # 搜索频率 + last_accessed = Column(DateTime) # 最后访问时间 + relevance_score = Column(Float) # 相关性评分 + class VehicleData(Base): """车辆实时数据模型""" __tablename__ = "vehicle_data" @@ -120,6 +134,12 @@ class Analytics(Base): category_distribution = Column(Text) # JSON格式的类别分布 created_at = Column(DateTime, default=datetime.now) + # 分析增强字段 + performance_score = Column(Float) # 性能评分 + quality_metrics = Column(Text) # 质量指标(JSON格式) + cost_analysis = Column(Text) # 成本分析(JSON格式) + optimization_suggestions = Column(Text) # 优化建议(JSON格式) + class Alert(Base): """预警模型""" __tablename__ = "alerts" diff --git a/src/integrations/config_manager.py b/src/integrations/config_manager.py index 3e14968..59737c9 100644 --- a/src/integrations/config_manager.py +++ b/src/integrations/config_manager.py @@ -177,9 +177,34 @@ class ConfigManager: return json.dumps(self.get_config_summary(), ensure_ascii=False, indent=2) def import_config(self, config_json: str) -> bool: - """导入配置(不再支持)""" - logger.warning("配置现在从 .env 文件读取,无法通过 API 导入") - return False + """导入配置 - 从 JSON 更新 .env 文件""" + try: + import json + config_data = json.loads(config_json) + + # 提取飞书配置 + feishu_config = config_data.get('feishu', {}) + + # 准备更新参数 + update_params = {} + if feishu_config.get('app_id'): + update_params['app_id'] = feishu_config['app_id'] + if feishu_config.get('app_secret') and feishu_config['app_secret'] != '***': + update_params['app_secret'] = feishu_config['app_secret'] + if feishu_config.get('app_token'): + update_params['app_token'] = feishu_config['app_token'] + if feishu_config.get('table_id'): + update_params['table_id'] = feishu_config['table_id'] + + if update_params: + return self.update_feishu_config(**update_params) + + logger.warning("导入的配置中没有有效的飞书配置") + return False + + except Exception as e: + logger.error(f"导入配置失败: {e}") + return False # 全局配置管理器实例 config_manager = ConfigManager() diff --git a/src/integrations/feishu_longconn_service.py b/src/integrations/feishu_longconn_service.py new file mode 100644 index 0000000..a520e27 --- /dev/null +++ b/src/integrations/feishu_longconn_service.py @@ -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 diff --git a/src/integrations/workorder_sync.py b/src/integrations/workorder_sync.py index b69c392..6565a8d 100644 --- a/src/integrations/workorder_sync.py +++ b/src/integrations/workorder_sync.py @@ -356,12 +356,20 @@ class WorkOrderSyncService: logger.warning(f"字段 '{key}' 包含复杂类型 {type(value).__name__},跳过") continue valid_data[key] = value - + workorder = WorkOrder(**valid_data) session.add(workorder) session.commit() session.refresh(workorder) - logger.info(f"创建工单成功: {workorder.order_id}") + + # 在session关闭前访问所有需要的属性,让SQLAlchemy加载它们 + workorder_id = workorder.id + workorder_order_id = workorder.order_id + + # 将对象从session中分离,这样它就不再依赖session + session.expunge(workorder) + + logger.info(f"创建工单成功: {workorder_order_id}") return workorder except Exception as e: logger.error(f"创建工单失败: {e}") @@ -371,6 +379,9 @@ class WorkOrderSyncService: """更新现有工单""" try: with db_manager.get_session() as session: + # 重新附加到session + session.add(workorder) + workorder.title = local_data.get("title", workorder.title) workorder.description = local_data.get("description", workorder.description) workorder.category = local_data.get("category", workorder.category) @@ -381,10 +392,18 @@ class WorkOrderSyncService: workorder.assignee = local_data.get("assignee", workorder.assignee) workorder.solution = local_data.get("solution", workorder.solution) workorder.ai_suggestion = local_data.get("ai_suggestion", workorder.ai_suggestion) - + session.commit() session.refresh(workorder) - logger.info(f"更新工单成功: {workorder.order_id}") + + # 在session关闭前访问需要的属性 + workorder_id = workorder.id + workorder_order_id = workorder.order_id + + # 从session中分离 + session.expunge(workorder) + + logger.info(f"更新工单成功: {workorder_order_id}") return workorder except Exception as e: logger.error(f"更新工单失败: {e}") @@ -423,20 +442,39 @@ class WorkOrderSyncService: logger.info(f"字段 '{field}' 的建议映射: {suggestions[0] if suggestions else '无'}") # 特殊字段处理 + # 定义所有日期时间字段 + datetime_fields = [ + "created_at", "updated_at", "date_of_close", + "dispatch_time", "operating_time" + ] + for local_field, value in local_data.items(): if local_field == "status" and value in self.status_mapping: local_data[local_field] = self.status_mapping[value] elif local_field == "priority" and value in self.priority_mapping: local_data[local_field] = self.priority_mapping[value] - elif local_field in ["created_at", "updated_at", "date_of_close"] and value: + elif local_field in datetime_fields: + # 处理所有日期时间字段 try: - if isinstance(value, (int, float)): - local_data[local_field] = datetime.fromtimestamp(value / 1000) - else: + if value is None or value == "": + local_data[local_field] = None + elif isinstance(value, (int, float)): + # 如果值为0或负数,设为None而不是转换 + if value <= 0: + local_data[local_field] = None + else: + local_data[local_field] = datetime.fromtimestamp(value / 1000) + elif isinstance(value, str): local_data[local_field] = datetime.fromisoformat(value.replace('Z', '+00:00')) + elif isinstance(value, datetime): + # 已经是datetime对象,保持不变 + pass + else: + # 其他类型设为None + local_data[local_field] = None except Exception as e: - logger.warning(f"时间字段转换失败: {e}, 使用当前时间") - local_data[local_field] = datetime.now() + logger.warning(f"时间字段 {local_field} 转换失败: {e}, 设为None") + local_data[local_field] = None # 生成标题:使用TR Description作为标题 tr_description = feishu_fields.get("TR Description", "") diff --git a/src/web/app.py b/src/web/app.py index ea6d5f7..cb0c23e 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -11,7 +11,7 @@ import logging from datetime import datetime, timedelta from typing import Dict, Any -from flask import Flask, render_template, request, jsonify, send_from_directory, make_response +from flask import Flask, render_template, request, jsonify, send_from_directory, make_response, session, redirect, url_for from flask_cors import CORS from src.config.unified_config import get_config @@ -43,23 +43,36 @@ from src.web.blueprints.feishu_bot import feishu_bot_bp # 配置日志 logger = logging.getLogger(__name__) -# 抑制 /api/health 的访问日志 +# 抑制高频监控接口的访问日志 werkzeug_logger = logging.getLogger('werkzeug') -class HealthLogFilter(logging.Filter): +class MonitoringLogFilter(logging.Filter): + """过滤掉高频监控接口的访问日志""" def filter(self, record): try: msg = record.getMessage() - return '/api/health' not in msg + # 过滤掉这些高频接口的日志 + quiet_endpoints = [ + '/api/health', + '/api/monitor/status', + '/api/analytics', + '/api/alerts' + ] + return not any(endpoint in msg for endpoint in quiet_endpoints) except Exception: return True -werkzeug_logger.addFilter(HealthLogFilter()) +werkzeug_logger.addFilter(MonitoringLogFilter()) # 创建Flask应用 app = Flask(__name__) CORS(app) +# 配置 session secret key(用于加密 session) +app.config['SECRET_KEY'] = 'tsp-assistant-secret-key-change-in-production' +app.config['SESSION_TYPE'] = 'filesystem' +app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # session 有效期 7 天 + # 配置上传文件夹 UPLOAD_FOLDER = 'uploads' app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER @@ -67,6 +80,37 @@ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max file size # 使用统一的服务管理器 +# 登录验证中间件 +@app.before_request +def check_login(): + """检查用户是否已登录""" + # 白名单:无需登录即可访问的路径 + whitelist = [ + '/login', + '/api/auth/login', + '/api/auth/status', + '/api/feishu/bot/event', # ✅ 飞书机器人回调 + '/static/', + '/uploads/' + ] + + # 调试日志(可以看到每个请求) + if request.path.startswith('/api/feishu/bot'): + logger.info(f"[DEBUG] 飞书机器人请求: {request.path}, 在白名单中: {any(request.path.startswith(path) for path in whitelist)}") + + # 检查当前路径是否在白名单中 + if any(request.path.startswith(path) for path in whitelist): + return None + + # 检查用户是否已登录 + if 'user_id' not in session: + # 如果是 API 请求,返回 401 + if request.path.startswith('/api/'): + return jsonify({"error": "未登录", "code": 401}), 401 + # 如果是页面请求,重定向到登录页 + else: + return redirect(url_for('login_page')) + # 注册蓝图 app.register_blueprint(alerts_bp) app.register_blueprint(workorders_bp) @@ -85,6 +129,11 @@ app.register_blueprint(feishu_bot_bp) # 页面路由 +@app.route('/login') +def login_page(): + """登录页面""" + return render_template('login.html') + @app.route('/') @app.route('/dashboard') def index(): diff --git a/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc b/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc index f0a64c7..5f308d9 100644 Binary files a/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc and b/src/web/blueprints/__pycache__/feishu_bot.cpython-310.pyc differ diff --git a/src/web/blueprints/__pycache__/system.cpython-310.pyc b/src/web/blueprints/__pycache__/system.cpython-310.pyc index b8d7ace..f9eef37 100644 Binary files a/src/web/blueprints/__pycache__/system.cpython-310.pyc and b/src/web/blueprints/__pycache__/system.cpython-310.pyc differ diff --git a/src/web/blueprints/feishu_bot.py b/src/web/blueprints/feishu_bot.py index c94b33e..a3a6d15 100644 --- a/src/web/blueprints/feishu_bot.py +++ b/src/web/blueprints/feishu_bot.py @@ -6,7 +6,7 @@ import logging import json import threading -from flask import Blueprint, request, jsonify +from flask import Blueprint, request, jsonify, current_app from src.integrations.feishu_service import FeishuService from src.web.service_manager import service_manager @@ -16,66 +16,117 @@ logger = logging.getLogger(__name__) # 创建蓝图 feishu_bot_bp = Blueprint('feishu_bot', __name__, url_prefix='/api/feishu/bot') -# 在模块级别实例化飞书服务,以便复用 -# 注意:这假设配置在启动时是固定的。如果配置可热更新,则需要调整。 -feishu_service = FeishuService() +def _process_message_in_background(app, event_data: dict): + """ + 在后台线程中处理消息,避免阻塞飞书的回调请求。 -def _process_message_in_background(app_context, event_data: dict): + Args: + app: Flask应用实例 + event_data: 飞书事件数据 """ - 在后台线程中处理消息,避免阻塞飞书的回调请求。 - """ - with app_context: + with app.app_context(): + # 每个线程创建独立的飞书服务实例,避免token共享问题 + feishu_service = FeishuService() + try: # 1. 解析事件数据 - message_id = event_data['event']['message']['message_id'] - chat_id = event_data['event']['message']['chat_id'] - # 内容是一个JSON字符串,需要再次解析 - content_json = json.loads(event_data['event']['message']['content']) - text_content = content_json.get('text', '').strip() + event = event_data.get('event', {}) + message = event.get('message', {}) + + message_id = message.get('message_id') + chat_id = message.get('chat_id') + + if not message_id or not chat_id: + logger.error(f"[Feishu Bot] 事件数据缺少必要字段: {event_data}") + return + + # 内容是一个JSON字符串,需要再次解析 + try: + content_json = json.loads(message.get('content', '{}')) + text_content = content_json.get('text', '').strip() + except json.JSONDecodeError as e: + logger.error(f"[Feishu Bot] 解析消息内容失败: {e}") + return logger.info(f"[Feishu Bot] 后台开始处理消息ID: {message_id}, 内容: '{text_content}'") # 2. 移除@机器人的部分 # 飞书的@消息格式通常是 "@机器人名 实际内容" - if event_data['event']['message'].get('mentions'): - for mention in event_data['event']['message']['mentions']: - # mention['key']是@内容,例如"@_user_1" + mentions = message.get('mentions', []) + if mentions: + for mention in mentions: + # mention['key']是@内容,例如"@_user_1" # mention['name']是显示的名字 - bot_mention_text = f"@{mention['name']}" - if text_content.startswith(bot_mention_text): - text_content = text_content[len(bot_mention_text):].strip() - break + mention_name = mention.get('name', '') + if 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"[Feishu Bot] 移除@后内容为空,不处理。消息ID: {message_id}") + logger.warning(f"[Feishu Bot] 移除@后内容为空,不处理。消息ID: {message_id}") + # 仍然回复一个提示 + feishu_service.reply_to_message(message_id, "您好!请问有什么可以帮助您的吗?") return logger.info(f"[Feishu Bot] 清理后的消息内容: '{text_content}'") - # 3. 调用核心服务获取回复 - assistant = service_manager.get_assistant() - # 注意:process_message_agent 是一个异步方法,需要处理 - # 在同步线程中运行异步方法 - import asyncio - try: - loop = asyncio.get_running_loop() - except RuntimeError: # 'RuntimeError: There is no current event loop...' - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) + # 3. 获取或创建该飞书用户的会话(支持群聊隔离) + chat_manager = service_manager.get_chat_manager() - # 调用对话服务 - logger.info(f"[Feishu Bot] 调用Agent服务处理消息...") - response_data = loop.run_until_complete( - assistant.process_message_agent(message=text_content, user_id=f"feishu_{chat_id}") + # 获取发送者ID(从event中提取) + sender_id = event.get('sender', {}).get('sender_id', {}).get('user_id', 'unknown') + + # 群聊隔离:每个用户在每个群都有独立会话 + # 格式:feishu_群聊ID_用户ID + user_id = f"feishu_{chat_id}_{sender_id}" + + logger.info(f"[Feishu Bot] 会话用户标识: {user_id}") + + # 检查是否已有活跃会话 + active_sessions = chat_manager.get_active_sessions() + session_id = None + for session in active_sessions: + if session.get('user_id') == user_id: + session_id = session.get('session_id') + logger.info(f"[Feishu Bot] 找到已有会话: {session_id}") + break + + # 如果没有会话,创建新会话 + if not session_id: + session_id = chat_manager.create_session(user_id=user_id, work_order_id=None) + logger.info(f"[Feishu Bot] 为用户 {sender_id} 在群聊 {chat_id} 创建新会话: {session_id}") + + # 4. 调用实时对话接口处理消息 + logger.info(f"[Feishu Bot] 调用实时对话接口处理消息...") + response_data = chat_manager.process_message( + session_id=session_id, + user_message=text_content, + ip_address=None, + invocation_method="feishu_bot" ) - logger.info(f"[Feishu Bot] Agent服务返回结果: {response_data}") + logger.info(f"[Feishu Bot] 实时对话接口返回结果: {response_data}") - # 4. 提取回复并发送 - reply_text = response_data.get("message", "抱歉,我暂时无法回答这个问题。") - if isinstance(reply_text, dict): # 有时候返回的可能是字典 + # 5. 提取回复并发送 + 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"抱歉,处理您的问题时遇到了一些问题。请稍后重试或联系客服。\n错误信息: {error_msg}" + logger.error(f"[Feishu Bot] 处理消息失败: {error_msg}") + + # 确保回复是字符串 + if isinstance(reply_text, dict): reply_text = reply_text.get('content', str(reply_text)) - logger.info(f"[Feishu Bot] 准备发送回复到飞书: '{reply_text}'") + if not isinstance(reply_text, str): + reply_text = str(reply_text) + + logger.info(f"[Feishu Bot] 准备发送回复到飞书 (长度: {len(reply_text)})") + logger.debug(f"[Feishu Bot] 回复内容: {reply_text}") + success = feishu_service.reply_to_message(message_id, reply_text) if success: @@ -83,8 +134,16 @@ def _process_message_in_background(app_context, event_data: dict): else: logger.error(f"[Feishu Bot] 回复消息到飞书失败。消息ID: {message_id}") + except KeyError as e: + logger.error(f"[Feishu Bot] 事件数据格式错误,缺少字段: {e}", exc_info=True) except Exception as e: logger.error(f"[Feishu Bot] 后台处理消息时发生严重错误: {e}", exc_info=True) + # 尝试发送错误提示给用户 + try: + if 'message_id' in locals(): + feishu_service.reply_to_message(message_id, "抱歉,系统遇到了一些问题,请稍后重试。") + except: + pass @feishu_bot_bp.route('/event', methods=['POST']) @@ -94,30 +153,46 @@ def handle_feishu_event(): """ # 1. 解析请求 data = request.json - logger.info(f"[Feishu Bot] 收到飞书事件回调:\n{json.dumps(data, indent=2)}") + + if not data: + logger.warning("[Feishu Bot] 收到空的请求数据") + return jsonify({"status": "error", "message": "empty request"}), 400 + + logger.info(f"[Feishu Bot] 收到飞书事件回调:\n{json.dumps(data, indent=2, ensure_ascii=False)}") # 2. 安全校验 (如果配置了) - # 此处可以添加Verification Token的校验逻辑 - # headers = request.headers - # ... + # 可以在这里添加Verification Token的校验逻辑 + # from src.config.unified_config import get_config + # config = get_config() + # if config.feishu.verification_token: + # token = request.headers.get('X-Lark-Request-Token') + # if token != config.feishu.verification_token: + # logger.warning("[Feishu Bot] Token验证失败") + # return jsonify({"status": "error", "message": "invalid token"}), 403 # 3. 处理URL验证挑战 - if data and data.get("type") == "url_verification": + if data.get("type") == "url_verification": challenge = data.get("challenge", "") - logger.info(f"[Feishu Bot] 收到URL验证请求,返回challenge: {challenge}") + logger.info(f"[Feishu Bot] 收到URL验证请求,返回challenge: {challenge}") return jsonify({"challenge": challenge}) # 4. 处理事件回调 - if data and data.get("header", {}).get("event_type") == "im.message.receive_v1": - # 立即响应飞书,防止超时重试 + event_type = data.get("header", {}).get("event_type") + + if event_type == "im.message.receive_v1": + # 获取当前Flask应用实例 + app = current_app._get_current_object() + + # 立即在后台线程中处理,避免阻塞飞书回调 threading.Thread( target=_process_message_in_background, - args=(request.environ['werkzeug.request'].environ['flask.app'].app_context(), data) + args=(app, data), + daemon=True # 设置为守护线程 ).start() - logger.info("[Feishu Bot] 已将消息处理任务推送到后台线程,并立即响应200 OK") + logger.info("[Feishu Bot] 已将消息处理任务推送到后台线程,并立即响应200 OK") return jsonify({"status": "processing"}) - # 5. 对于其他未知事件,也返回成功,避免飞书重试 - logger.warning(f"[Feishu Bot] 收到未知类型的事件: {data.get('header', {}).get('event_type')}") + # 5. 对于其他未知事件,也返回成功,避免飞书重试 + logger.warning(f"[Feishu Bot] 收到未知类型的事件: {event_type}") return jsonify({"status": "ignored"}) diff --git a/src/web/templates/login.html b/src/web/templates/login.html new file mode 100644 index 0000000..a22b4e4 --- /dev/null +++ b/src/web/templates/login.html @@ -0,0 +1,287 @@ + + + + + + TSP 智能助手 - 登录 + + + + + + + + + + + diff --git a/start_feishu_bot.py b/start_feishu_bot.py new file mode 100755 index 0000000..cd4f852 --- /dev/null +++ b/start_feishu_bot.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# 如果系统中 python 命令不存在,请使用: python3 start_feishu_bot.py +# -*- coding: utf-8 -*- +""" +启动飞书长连接客户端 +使用官方 SDK 的事件订阅 2.0 模式,无需公网域名 +""" +import sys +import os +import logging +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +# 配置日志 +import os + +# 从环境变量获取日志级别,默认为 INFO +log_level = os.getenv('LOG_LEVEL', 'INFO').upper() + +# 配置日志格式 +logging.basicConfig( + level=getattr(logging, log_level, logging.INFO), + format='%(asctime)s [%(levelname)s] [%(name)s] %(message)s', + datefmt='%Y-%m-%d %H:%M:%S' +) + +logger = logging.getLogger(__name__) + + +def main(): + """主函数""" + # 加载配置 + from src.config.unified_config import get_config + config = get_config() + + logger.info("=" * 80) + logger.info("🚀 TSP Assistant - 飞书长连接客户端") + logger.info("=" * 80) + logger.info("") + logger.info("📋 配置信息:") + logger.info(f" - App ID: {config.feishu.app_id}") + logger.info(f" - 数据库: {config.database.url}") + logger.info(f" - LLM: {config.llm.provider}/{config.llm.model}") + logger.info(f" - 日志级别: {log_level}") + logger.info("") + logger.info("🔌 启动模式: 事件订阅 2.0(长连接)") + logger.info("✅ 优势:") + logger.info(" - 无需公网域名") + logger.info(" - 无需配置 webhook") + logger.info(" - 自动重连") + logger.info(" - 实时接收消息") + logger.info("") + logger.info("💡 提示: 按 Ctrl+C 停止服务") + logger.info("=" * 80) + logger.info("") + + # 导入并启动长连接服务 + from src.integrations.feishu_longconn_service import get_feishu_longconn_service + + try: + service = get_feishu_longconn_service() + service.start() # 这里会阻塞,持续监听飞书事件 + except KeyboardInterrupt: + logger.info("") + logger.info("⏹️ 用户中断,正在停止服务...") + logger.info("👋 再见!") + except Exception as e: + logger.error(f"❌ 服务异常退出: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + main()