356 lines
15 KiB
Python
356 lines
15 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') or msg.get('media_id', ''))[:50])
|
||
|
||
wecom_userid = msg.get('from_user')
|
||
msg_type = msg.get('msg_type')
|
||
content = msg.get('content', '').strip()
|
||
|
||
# 文本消息
|
||
if msg_type == 'text':
|
||
if not wecom_userid or not content:
|
||
return Response("", content_type='text/plain')
|
||
|
||
db_name = request.env.cr.dbname
|
||
threading.Thread(
|
||
target=self._async_process_ai_message,
|
||
args=(db_name, wecom_userid, content, None),
|
||
daemon=True,
|
||
).start()
|
||
return Response("", content_type='text/plain')
|
||
|
||
# 图片消息:下载 → OCR → 交给 AI
|
||
if msg_type == 'image':
|
||
media_id = msg.get('media_id', '')
|
||
if not wecom_userid or not media_id:
|
||
return Response("", content_type='text/plain')
|
||
|
||
_logger.info("收到企微图片消息: user=%s, media_id=%s", wecom_userid, media_id[:20])
|
||
db_name = request.env.cr.dbname
|
||
threading.Thread(
|
||
target=self._async_process_ai_message,
|
||
args=(db_name, wecom_userid, "请帮我看看这张图片里有什么", media_id, 'image'),
|
||
daemon=True,
|
||
).start()
|
||
return Response("", content_type='text/plain')
|
||
|
||
# 文件消息(Excel/CSV):下载 → 解析 → 交给 AI
|
||
if msg_type == 'file':
|
||
media_id = msg.get('media_id', '')
|
||
if not wecom_userid or not media_id:
|
||
return Response("", content_type='text/plain')
|
||
|
||
_logger.info("收到企微文件消息: user=%s, media_id=%s", wecom_userid, media_id[:20])
|
||
db_name = request.env.cr.dbname
|
||
threading.Thread(
|
||
target=self._async_process_ai_message,
|
||
args=(db_name, wecom_userid, "请帮我分析这个文件的内容", media_id, 'file'),
|
||
daemon=True,
|
||
).start()
|
||
return Response("", content_type='text/plain')
|
||
|
||
# 其他类型暂不处理
|
||
_logger.info("AI回调收到非文本/图片/文件消息类型: %s,暂不处理", msg_type)
|
||
return Response("", content_type='text/plain')
|
||
|
||
def _async_process_ai_message(self, db_name, wecom_userid, content, media_id=None, media_type='image'):
|
||
"""
|
||
后台线程: 处理 AI 对话并推送回复
|
||
使用独立的数据库游标和环境
|
||
|
||
:param media_id: 可选,图片/文件消息的 media_id
|
||
:param media_type: 'image' | 'file'
|
||
"""
|
||
try:
|
||
registry = api.Registry(db_name)
|
||
with registry.cursor() as cr:
|
||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||
|
||
attachment_ids = None
|
||
if media_id:
|
||
if media_type == 'file':
|
||
attachment_ids = self._download_and_parse_file(env, wecom_userid, media_id)
|
||
else:
|
||
attachment_ids = self._download_and_ocr_media(env, wecom_userid, media_id)
|
||
|
||
if not attachment_ids:
|
||
# 下载/解析失败,给用户一个提示,不再调AI
|
||
if media_type == 'file':
|
||
env['wecom.ai.chat']._send_wecom_reply(
|
||
wecom_userid, "文件已收到,但解析失败。请确保发送的是 .xlsx/.xls/.csv 格式的 Excel 文件。")
|
||
else:
|
||
env['wecom.ai.chat']._send_wecom_reply(
|
||
wecom_userid, "图片已收到,但未能从中提取到文字信息。请尝试发送更清晰的图片(含印刷文字)。")
|
||
cr.commit()
|
||
return
|
||
|
||
env['wecom.ai.chat'].process_ai_message(wecom_userid, content, attachment_ids)
|
||
cr.commit()
|
||
except Exception as e:
|
||
_logger.exception("AI消息异步处理失败 [user=%s]: %s", wecom_userid, e)
|
||
|
||
def _download_and_ocr_media(self, env, wecom_userid, media_id):
|
||
"""下载企微图片 → 创建 documents.document → OCR → 返回 [document_id]
|
||
|
||
:return: [int] 或 None
|
||
"""
|
||
try:
|
||
# 1. 下载图片
|
||
content, error = env['wecom.apps'].sudo().download_agent_media(media_id)
|
||
if content is None:
|
||
_logger.error('[WECOM-OCR] 下载失败: %s', error)
|
||
return None
|
||
|
||
_logger.info('[WECOM-OCR] 下载成功: %d bytes', len(content))
|
||
|
||
# 2. 确定 mimetype(根据魔数)
|
||
mimetype = 'image/jpeg'
|
||
if content[:4] == b'\x89PNG':
|
||
mimetype = 'image/png'
|
||
elif content[:2] == b'\xff\xd8':
|
||
mimetype = 'image/jpeg'
|
||
elif content[:4] == b'GIF8':
|
||
mimetype = 'image/gif'
|
||
elif content[:4] == b'%PDF':
|
||
mimetype = 'application/pdf'
|
||
|
||
ext_map = {'image/jpeg': '.jpg', 'image/png': '.png', 'image/gif': '.gif', 'application/pdf': '.pdf'}
|
||
ext = ext_map.get(mimetype, '.jpg')
|
||
filename = f'wecom_image_{media_id[:8]}{ext}'
|
||
|
||
# 3. 创建 documents.document + OCR
|
||
# 复用 ai_agent 控制器里的逻辑:找/建文件夹 + 存文件 + OCR
|
||
DocFolder = env['documents.folder'].sudo()
|
||
folder = DocFolder.search([('name', '=', 'AI 聊天附件')], limit=1)
|
||
if not folder:
|
||
folder = DocFolder.create({'name': 'AI 聊天附件'})
|
||
|
||
document = env['documents.document'].sudo().create({
|
||
'name': filename,
|
||
'folder_id': folder.id,
|
||
'mimetype': mimetype,
|
||
'raw': content,
|
||
})
|
||
|
||
_logger.info('[WECOM-OCR] 文档已创建: id=%s', document.id)
|
||
|
||
# 4. OCR
|
||
provider = env['ai.provider'].sudo().search([('active', '=', True)], limit=1)
|
||
if provider and provider.ocr_enabled:
|
||
try:
|
||
from odoo.addons.yuthon_ai_agent.models.ocr_provider import get_ocr_instance
|
||
except ImportError:
|
||
_logger.warning('[WECOM-OCR] 无法导入 OCR 模块')
|
||
return [document.id]
|
||
ocr, init_err = get_ocr_instance(provider)
|
||
if ocr is not None:
|
||
result = ocr.recognize_general(content, mimetype)
|
||
if result.get('success'):
|
||
ocr_text = result.get('text') or ''
|
||
if ocr_text:
|
||
document.write({'ai_ocr_content': ocr_text})
|
||
_logger.info('[WECOM-OCR] ✅ 识别成功: %d字符', len(ocr_text))
|
||
else:
|
||
_logger.info('[WECOM-OCR] ⚠️ 图片中无文字')
|
||
else:
|
||
_logger.error('[WECOM-OCR] ❌ %s', result.get('error'))
|
||
else:
|
||
_logger.warning('[WECOM-OCR] OCR 实例创建失败: %s', init_err)
|
||
else:
|
||
_logger.info('[WECOM-OCR] OCR 未启用或没有活跃的 AI 服务商')
|
||
|
||
return [document.id]
|
||
except Exception as e:
|
||
_logger.exception('[WECOM-OCR] 下载/OCR 异常: %s', e)
|
||
return None
|
||
|
||
def _download_and_parse_file(self, env, wecom_userid, media_id):
|
||
"""下载企微文件 → 创建 documents.document → Excel解析 → 返回 [document_id]
|
||
|
||
支持 .xlsx / .xls / .csv
|
||
:return: [int] 或 None
|
||
"""
|
||
try:
|
||
# 1. 下载文件
|
||
content, error = env['wecom.apps'].sudo().download_agent_media(media_id)
|
||
if content is None:
|
||
_logger.error('[WECOM-FILE] 下载失败: %s', error)
|
||
return None
|
||
|
||
_logger.info('[WECOM-FILE] 下载成功: %d bytes', len(content))
|
||
|
||
# 2. 确定 mimetype(根据魔数)
|
||
mimetype = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
ext = '.xlsx'
|
||
if content[:2] == b'PK':
|
||
mimetype = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
ext = '.xlsx'
|
||
elif content[:2] == b'\xd0\xcf':
|
||
mimetype = 'application/vnd.ms-excel'
|
||
ext = '.xls'
|
||
else:
|
||
# 尝试按文本解码 → CSV
|
||
try:
|
||
_ = content.decode('utf-8-sig')
|
||
mimetype = 'text/csv'
|
||
ext = '.csv'
|
||
except:
|
||
_logger.warning('[WECOM-FILE] 无法识别文件类型,降级为 xlsx')
|
||
filename = f'wecom_file_{media_id[:8]}{ext}'
|
||
|
||
# 3. 创建 documents.document
|
||
DocFolder = env['documents.folder'].sudo()
|
||
folder = DocFolder.search([('name', '=', 'AI 聊天附件')], limit=1)
|
||
if not folder:
|
||
folder = DocFolder.create({'name': 'AI 聊天附件'})
|
||
|
||
document = env['documents.document'].sudo().create({
|
||
'name': filename,
|
||
'folder_id': folder.id,
|
||
'mimetype': mimetype,
|
||
'raw': content,
|
||
})
|
||
|
||
_logger.info('[WECOM-FILE] 文档已创建: id=%s', document.id)
|
||
|
||
# 4. Excel 解析
|
||
try:
|
||
from odoo.addons.yuthon_ai_agent.utils.excel_parser import parse_excel_to_markdown
|
||
except ImportError:
|
||
_logger.warning('[WECOM-FILE] 无法导入 Excel 解析器')
|
||
return [document.id]
|
||
|
||
markdown_text, meta = parse_excel_to_markdown(content, filename)
|
||
if meta.get('error'):
|
||
_logger.error('[WECOM-FILE] 解析失败: %s', meta['error'])
|
||
return [document.id]
|
||
|
||
document.write({'ai_ocr_content': markdown_text})
|
||
_logger.info('[WECOM-FILE] ✅ 解析成功: %d字符 | sheets=%s | rows=%s',
|
||
len(markdown_text), meta.get('sheets'), meta.get('total_rows'))
|
||
|
||
# 5. 创建导入记录
|
||
try:
|
||
env['ai.import.record'].sudo().create({
|
||
'document_id': document.id,
|
||
'filename': filename,
|
||
'file_type': 'excel',
|
||
'user_id': env.ref('base.user_root').id,
|
||
'wecom_userid': wecom_userid,
|
||
'ocr_status': 'success' if not meta.get('truncated') else 'success',
|
||
'ocr_content': markdown_text,
|
||
})
|
||
except Exception as e:
|
||
_logger.warning('[WECOM-FILE] 创建导入记录失败: %s', e)
|
||
|
||
return [document.id]
|
||
except Exception as e:
|
||
_logger.exception('[WECOM-FILE] 下载/解析异常: %s', e)
|
||
return None
|