Files
2026-06-12 18:10:34 +08:00

356 lines
15 KiB
Python
Raw Permalink 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.
# -*- 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