145 lines
5.8 KiB
Python
145 lines
5.8 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
企业微信 AI 智能体消息回调控制器
|
||
接收企业微信用户发送给 AI 应用的消息,异步处理后主动推送回复
|
||
"""
|
||
|
||
import logging
|
||
import threading
|
||
|
||
from odoo import http, api, SUPERUSER_ID
|
||
from odoo.http import request, Response
|
||
|
||
from ..utils.wx_biz_msg_crypt import WXBizMsgCrypt, WXBizMsgCryptError, parse_wecom_message
|
||
|
||
_logger = logging.getLogger(__name__)
|
||
|
||
|
||
class WecomAiCallback(http.Controller):
|
||
"""企业微信 AI 消息回调"""
|
||
|
||
def _get_ai_app_config(self):
|
||
"""获取 AI 智能体对应的企业微信应用配置"""
|
||
config = request.env['wecom.apps.config'].sudo().search([
|
||
('wecom_apps_type', '=', 'ai_callback'),
|
||
('wecom_apps_category', '=', 'agent'),
|
||
], limit=1)
|
||
if not config or not config.wecom_apps_id:
|
||
return None
|
||
return config.wecom_apps_id
|
||
|
||
def _get_crypt_instance(self, wecom_app):
|
||
"""创建加解密实例"""
|
||
token = wecom_app.callback_token
|
||
aes_key = wecom_app.encoding_aes_key
|
||
corpid = wecom_app.corpid
|
||
if not all([token, aes_key, corpid]):
|
||
_logger.error("企业微信 AI 回调配置不完整: token=%s, aes_key=%s, corpid=%s",
|
||
bool(token), bool(aes_key), bool(corpid))
|
||
return None
|
||
return WXBizMsgCrypt(token, aes_key, corpid)
|
||
|
||
@http.route('/wecom/ai/callback', type='http', auth='none',
|
||
methods=['GET', 'POST'], csrf=False)
|
||
def ai_callback(self, msg_signature=None, timestamp=None, nonce=None,
|
||
echostr=None, **kw):
|
||
"""
|
||
企业微信 AI 应用消息回调入口
|
||
|
||
GET: URL验证(配置接收消息服务器时企业微信发起的验证请求)
|
||
POST: 接收用户消息
|
||
"""
|
||
# 获取 AI 应用配置
|
||
wecom_app = self._get_ai_app_config()
|
||
if not wecom_app:
|
||
_logger.error("未找到 AI 回调应用配置(wecom_apps_type=ai_callback, category=agent)")
|
||
return Response("config error", content_type='text/plain')
|
||
|
||
crypt = self._get_crypt_instance(wecom_app)
|
||
if not crypt:
|
||
return Response("config error", content_type='text/plain')
|
||
|
||
# === GET: URL验证 ===
|
||
if request.httprequest.method == 'GET':
|
||
return self._handle_verify(crypt, msg_signature, timestamp, nonce, echostr)
|
||
|
||
# === POST: 接收消息 ===
|
||
return self._handle_message(crypt, msg_signature, timestamp, nonce)
|
||
|
||
def _handle_verify(self, crypt, msg_signature, timestamp, nonce, echostr):
|
||
"""处理URL验证请求"""
|
||
if not all([msg_signature, timestamp, nonce, echostr]):
|
||
_logger.warning("AI回调URL验证缺少参数: sig=%s, ts=%s, nonce=%s, echo=%s",
|
||
msg_signature, timestamp, nonce, bool(echostr))
|
||
return Response("param error", content_type='text/plain')
|
||
try:
|
||
echo_plain = crypt.verify_url(msg_signature, timestamp, nonce, echostr)
|
||
_logger.info("企业微信 AI 回调URL验证成功")
|
||
return Response(echo_plain, content_type='text/plain')
|
||
except WXBizMsgCryptError as e:
|
||
_logger.error("AI回调URL验证失败: %s", e)
|
||
return Response("verify error", content_type='text/plain')
|
||
|
||
def _handle_message(self, crypt, msg_signature, timestamp, nonce):
|
||
"""处理接收消息请求"""
|
||
if not all([msg_signature, timestamp, nonce]):
|
||
_logger.warning("AI回调消息缺少签名参数")
|
||
return Response("", content_type='text/plain')
|
||
|
||
post_data = request.httprequest.get_data(as_text=True)
|
||
if not post_data:
|
||
_logger.warning("AI回调消息体为空")
|
||
return Response("", content_type='text/plain')
|
||
|
||
# 解密消息
|
||
try:
|
||
xml_plain = crypt.decrypt_msg(msg_signature, timestamp, nonce, post_data)
|
||
except WXBizMsgCryptError as e:
|
||
_logger.error("AI回调消息解密失败: %s", e)
|
||
return ""
|
||
|
||
# 解析消息
|
||
msg = parse_wecom_message(xml_plain)
|
||
if not msg:
|
||
_logger.error("AI回调消息XML解析失败")
|
||
return ""
|
||
|
||
_logger.info("收到企业微信AI消息: from=%s, type=%s, content=%s",
|
||
msg.get('from_user'), msg.get('msg_type'),
|
||
msg.get('content', '')[:50])
|
||
|
||
# 只处理文本消息
|
||
if msg.get('msg_type') != 'text':
|
||
_logger.info("AI回调收到非文本消息类型: %s,暂不处理", msg.get('msg_type'))
|
||
return Response("", content_type='text/plain')
|
||
|
||
wecom_userid = msg.get('from_user')
|
||
content = msg.get('content', '').strip()
|
||
if not wecom_userid or not content:
|
||
return Response("", content_type='text/plain')
|
||
|
||
# 异步处理: 立即返回空响应,后台线程处理AI对话并推送回复
|
||
db_name = request.env.cr.dbname
|
||
threading.Thread(
|
||
target=self._async_process_ai_message,
|
||
args=(db_name, wecom_userid, content),
|
||
daemon=True,
|
||
).start()
|
||
|
||
# 立即返回空字符串,表示成功接收
|
||
return Response("", content_type='text/plain')
|
||
|
||
def _async_process_ai_message(self, db_name, wecom_userid, content):
|
||
"""
|
||
后台线程: 处理 AI 对话并推送回复
|
||
使用独立的数据库游标和环境
|
||
"""
|
||
try:
|
||
registry = api.Registry(db_name)
|
||
with registry.cursor() as cr:
|
||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||
env['wecom.ai.chat'].process_ai_message(wecom_userid, content)
|
||
cr.commit()
|
||
except Exception as e:
|
||
_logger.exception("AI消息异步处理失败 [user=%s]: %s", wecom_userid, e)
|