294 lines
8.6 KiB
Python
294 lines
8.6 KiB
Python
"""
|
||
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_capture 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)
|