378 lines
11 KiB
Python
378 lines
11 KiB
Python
"""
|
|
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)
|