""" Feishu Integration Service - 飞书集成服务 功能: 1. 接收飞书群聊消息事件 2. AI 判断消息是否为有价值的信号 3. 自动创建信号并关联到项目 4. 推送通知给相关角色 飞书开放平台配置: - App ID: 从飞书开放平台获取 - App Secret: 从飞书开放平台获取 - Encrypt Key: 事件订阅加密密钥 - Verification Token: 事件订阅验证令牌 """ import asyncio import json import hashlib import hmac import time from datetime import datetime, timezone from typing import Any, Dict, List, Optional from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy import text from ..models import ( User, Project, Signal, ProjectSignalLink, Integration ) from .signal_capture import ProactiveSignalCaptureService class FeishuIntegrationService: """飞书集成服务""" def __init__(self, db: AsyncSession): self.db = db # 从数据库获取飞书配置 self.app_id = None self.app_secret = None self.encrypt_key = None self.verification_token = None self.tenant_id = 1 async def load_config(self, tenant_id: int = 1): """从数据库加载飞书配置""" self.tenant_id = tenant_id result = await self.db.execute( select(Integration).where( Integration.tenant_id == tenant_id, Integration.provider == 'feishu' ) ) integration = result.scalar_one_or_none() if integration: config = json.loads(integration.config) if integration.config else {} self.app_id = config.get('app_id') self.app_secret = config.get('app_secret') self.encrypt_key = config.get('encrypt_key') self.verification_token = config.get('verification_token') def is_configured(self) -> bool: """检查是否已配置""" return all([ self.app_id, self.app_secret, self.encrypt_key, self.verification_token ]) async def save_config( self, app_id: str, app_secret: str, encrypt_key: str, verification_token: str, tenant_id: int = 1 ): """保存飞书配置到数据库""" config = { 'app_id': app_id, 'app_secret': app_secret, 'encrypt_key': encrypt_key, 'verification_token': verification_token } # 查找或创建集成记录 result = await self.db.execute( select(Integration).where( Integration.tenant_id == tenant_id, Integration.provider == 'feishu' ) ) integration = result.scalar_one_or_none() if integration: integration.config = json.dumps(config) integration.is_active = True integration.last_synced_at = datetime.now(timezone.utc) else: from sqlalchemy import text next_id = (await self.db.execute(text("SELECT COALESCE(MAX(id),0)+1 FROM integrations"))).scalar() or 1 integration = Integration( id=next_id, provider='feishu', name='飞书集成', config=json.dumps(config), is_active=True, last_synced_at=datetime.now(timezone.utc), tenant_id=tenant_id ) self.db.add(integration) await self.db.commit() # 更新内存配置 self.app_id = app_id self.app_secret = app_secret self.encrypt_key = encrypt_key self.verification_token = verification_token self.tenant_id = tenant_id async def verify_url(self, challenge: str) -> str: """ 验证飞书事件订阅 URL 飞书会发送一个 GET 请求来验证 URL 有效性 """ if not self.is_configured(): raise ValueError("飞书未配置") # 返回 challenge 值即可完成验证 return challenge def verify_signature(self, timestamp: str, nonce: str, body: str, signature: str) -> bool: """ 验证飞书事件签名 防止伪造请求 """ if not self.encrypt_key: return False # 构造签名字符串 sign_str = f"{self.encrypt_key}{nonce}{timestamp}{body}" computed_signature = hashlib.sha256(sign_str.encode()).hexdigest() # 飞书返回的签名需要去掉 "sha256=" 前缀 expected_signature = signature.replace("sha256=", "") return computed_signature == expected_signature async def handle_message_event( self, event_data: Dict[str, Any] ) -> Dict[str, Any]: """ 处理飞书消息事件 支持的事件类型: - message.receive_v1: 收到群聊消息 """ try: event_type = event_data.get('type', '') if event_type == 'message.receive_v1': return await self._handle_message_receive(event_data) return { 'code': 0, 'msg': f'Event type {event_type} not handled' } except Exception as e: print(f"[Feishu] Handle event error: {e}") return { 'code': 1, 'msg': str(e) } async def _handle_message_receive( self, event: Dict[str, Any] ) -> Dict[str, Any]: """处理收到的消息事件""" # 解析消息内容 message_content = self._extract_message_content(event) if not message_content: return {'code': 0, 'msg': 'No text content'} # 获取发送者信息 sender_name = event.get('sender', {}).get('sender_name', '未知用户') # 获取群聊信息 chat_id = event.get('chat', {}).get('chat_id', '') # 使用信号捕获服务处理 capture_service = ProactiveSignalCaptureService(self.db) result = await capture_service.capture_from_feishu( group_chat_id=chat_id, message_content=message_content, sender=sender_name, tenant_id=self.tenant_id ) print(f"[Feishu] Message processed: {result.get('status')}") return { 'code': 0, 'msg': 'success' } def _extract_message_content(self, event: Dict[str, Any]) -> Optional[str]: """提取消息文本内容""" message = event.get('message', {}) # 处理文本消息 message_type = message.get('message_type', '') if message_type == 'text': content = message.get('content', '') if content: try: content_json = json.loads(content) return content_json.get('text', '') except: pass # 处理富文本消息 elif message_type == 'post': content = message.get('content', '') if content: try: content_json = json.loads(content) post = content_json.get('post', {}) zh_cn = post.get('zh_cn', {}) elements = zh_cn.get('content', []) or [] text_parts = [] for elem in elements: if elem.get('tag') == 'text': text_parts.append(elem.get('text', '')) return ' '.join(text_parts) except: pass return None async def send_message_to_group( self, chat_id: str, message: str ) -> Dict[str, Any]: """ 发送消息到飞书群聊 用于推送通知、简报等 """ if not self.is_configured(): raise ValueError("飞书未配置") # 获取 tenant_access_token access_token = await self._get_tenant_access_token() # 发送消息 API url = "https://open.feishu.cn/open-apis/im/v1/messages" headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } message_data = { 'receive_id_type': 'chat_id', 'receive_id': chat_id, 'msg_type': 'text', 'content': json.dumps({'text': message}) } import aiohttp async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=message_data) as resp: result = await resp.json() return result async def _get_tenant_access_token(self) -> str: """获取 tenant_access_token""" if not self.app_id or not self.app_secret: raise ValueError("飞书未配置") url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" headers = {'Content-Type': 'application/json'} data = { 'app_id': self.app_id, 'app_secret': self.app_secret } import aiohttp async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=data) as resp: result = await resp.json() if result.get('code') == 0: return result.get('tenant_access_token') else: raise Exception(f"获取 token 失败: {result}") async def send_rich_card( self, chat_id: str, title: str, content: List[Dict[str, Any]] ) -> Dict[str, Any]: """ 发送富文本卡片消息 用于发送格式化的简报、通知等 """ if not self.is_configured(): raise ValueError("飞书未配置") access_token = await self._get_tenant_access_token() url = "https://open.feishu.cn/open-apis/im/v1/messages" headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } # 构造卡片内容 card_content = { 'config': {'wide_screen_mode': True}, 'header': { 'title': { 'content': title, 'tag': 'plain_text' } }, 'elements': [] } for item in content: if item.get('type') == 'text': card_content['elements'].append({ 'tag': 'div', 'text': { 'content': item.get('text', ''), 'tag': 'lark_md' } }) elif item.get('type') == 'divider': card_content['elements'].append({'tag': 'hr'}) message_data = { 'receive_id_type': 'chat_id', 'receive_id': chat_id, 'msg_type': 'interactive', 'content': json.dumps(card_content) } import aiohttp async with aiohttp.ClientSession() as session: async with session.post(url, headers=headers, json=message_data) as resp: result = await resp.json() return result # 全局服务实例 _feishu_service: Optional[FeishuIntegrationService] = None def get_feishu_service(db: AsyncSession) -> FeishuIntegrationService: """获取飞书服务实例""" return FeishuIntegrationService(db)