# -*- 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