PMFlow/backend/app/services/integrations/wecom/service.py

294 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
WeChat Work (企业微信) Integration Service
功能:
1. 接收企业微信群聊消息事件
2. AI 判断消息是否为有价值的信号
3. 自动创建信号并关联到项目
4. 推送通知给相关角色
"""
import asyncio
import json
import hashlib
import hmac
import base64
import struct
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import text
from ....models import (
Signal, Project, Integration
)
from ...signal.signal_capture_service import ProactiveSignalCaptureService
class WeChatWorkIntegrationService:
"""企业微信集成服务"""
def __init__(self, db: AsyncSession):
self.db = db
# 从数据库获取企业微信配置
self.corp_id = None
self.agent_id = None
self.app_secret = None
self.token = None
self.encoding_aes_key = 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.type == 'wecom',
Integration.is_active.is_(True),
)
)
integration = result.scalar_one_or_none()
if integration:
if isinstance(integration.config, str):
config = json.loads(integration.config) if integration.config else {}
else:
config = integration.config or {}
self.corp_id = config.get('corp_id')
self.agent_id = config.get('agent_id')
self.app_secret = config.get('app_secret')
self.token = config.get('token')
self.encoding_aes_key = config.get('encoding_aes_key')
else:
self.corp_id = None
self.agent_id = None
self.app_secret = None
self.token = None
self.encoding_aes_key = None
def is_configured(self) -> bool:
"""检查是否已配置"""
return all([
self.corp_id,
self.agent_id,
self.app_secret,
self.token,
self.encoding_aes_key
])
async def save_config(
self,
corp_id: str,
agent_id: str,
app_secret: str,
token: str,
encoding_aes_key: str,
tenant_id: int = 1
):
"""保存企业微信配置到数据库"""
config = {
'corp_id': corp_id,
'agent_id': agent_id,
'app_secret': app_secret,
'token': token,
'encoding_aes_key': encoding_aes_key
}
# 查找或创建集成记录
result = await self.db.execute(
select(Integration).where(
Integration.tenant_id == tenant_id,
Integration.type == 'wecom'
)
)
integration = result.scalar_one_or_none()
if integration:
integration.config = config
integration.is_active = True
else:
integration = Integration(
type='wecom',
config=config,
is_active=True,
tenant_id=tenant_id
)
self.db.add(integration)
await self.db.commit()
# 更新内存配置
self.corp_id = corp_id
self.agent_id = agent_id
self.app_secret = app_secret
self.token = token
self.encoding_aes_key = encoding_aes_key
self.tenant_id = tenant_id
def verify_url(self, msg_signature: str, timestamp: str, nonce: str, echostr: str) -> str:
"""
验证企业微信事件订阅 URL
企业微信会发送一个 GET 请求来验证 URL 有效性
"""
if not self.is_configured():
raise ValueError("企业微信未配置")
# 计算签名
sign_str = f"{self.token}{timestamp}{nonce}"
signature = hashlib.sha256(sign_str.encode()).hexdigest()
if signature != msg_signature:
raise ValueError("签名验证失败")
# 解密 echostr
decrypted = self._decrypt(echostr)
return decrypted
def verify_callback(self, msg_signature: str, timestamp: str, nonce: str, data: str) -> bool:
"""
验证企业微信回调签名
"""
if not self.token:
return False
# 计算签名
sign_str = f"{self.token}{timestamp}{nonce}{data}"
computed_signature = hashlib.sha256(sign_str.encode()).hexdigest()
return computed_signature == msg_signature
async def handle_message_event(
self,
event_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
处理企业微信消息事件
支持的事件类型:
- add_half_user: 添加企业成员
- update_user: 更新成员信息
- send_msg_success: 消息发送成功
"""
try:
event_type = event_data.get('Event', '')
# 企业微信群聊消息通过其他方式处理,这里主要是事件处理
return {
'code': 0,
'msg': f'Event {event_type} received'
}
except Exception as e:
print(f"[WeCom] Handle event error: {e}")
return {
'code': 1,
'msg': str(e)
}
def _decrypt(self, encoded_text: str) -> str:
"""
解密企业微信加密消息
AES 加密的消息格式: base64编码的16字节随机字符串 + 4字节消息长度 + 消息内容
"""
try:
# Base64 解码
cipher_text = base64.b64decode(encoded_text)
# AES 密钥处理
aes_key = base64.b64decode(self.encoding_aes_key + "=")
# 解密
from Crypto.Cipher import AES
cipher = AES.new(aes_key, AES.MODE_CBC, aes_key[:16])
decrypted = cipher.decrypt(cipher_text)
# 去除补位字符
pad = decrypted[-1]
if isinstance(pad, str):
pad = ord(pad)
decrypted = decrypted[:-pad]
# 提取消息内容(前16字节是随机字符串,接着4字节是消息长度)
content = decrypted[16:]
msg_len = struct.unpack(">I", content[:4])[0]
message = content[4:4 + msg_len].decode('utf-8')
return message
except Exception as e:
print(f"[WeCom] Decrypt error: {e}")
raise ValueError("消息解密失败")
async def send_message_to_group(
self,
chat_id: str,
message: str
) -> Dict[str, Any]:
"""
发送消息到企业微信群聊
用于推送通知、简报等
"""
if not self.is_configured():
raise ValueError("企业微信未配置")
# 获取 access_token
access_token = await self._get_access_token()
# 发送消息 API
url = "https://qyapi.weixin.qq.com/cgi-bin/message/send"
params = {"access_token": access_token}
message_data = {
"touser": "",
"msgtype": "text",
"agentid": int(self.agent_id),
"text": {
"content": message
},
"safe": 0
}
# 如果是群聊,使用 chatid
if chat_id:
message_data["chatid"] = chat_id
message_data["msgtype"] = "text"
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
resp = await client.post(url, params=params, json=message_data)
resp.raise_for_status()
return resp.json()
async def _get_access_token(self) -> str:
"""获取企业微信 access_token"""
if not self.corp_id or not self.app_secret:
raise ValueError("企业微信未配置")
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.app_secret
}
async with httpx.AsyncClient(timeout=20.0, trust_env=False) as client:
resp = await client.get(url, params=params)
resp.raise_for_status()
result = resp.json()
if result.get('errcode') == 0:
return result.get('access_token')
raise Exception(f"获取 token 失败: {result}")
# 全局服务实例
def get_wecom_service(db: AsyncSession) -> WeChatWorkIntegrationService:
"""获取企业微信服务实例"""
return WeChatWorkIntegrationService(db)